appilot-mcp 0.2.1 → 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.
Files changed (36) 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 +74 -16
  5. package/dist/appilot-configurator.mcpb +0 -0
  6. package/dist/cli.d.ts +34 -0
  7. package/dist/cli.js +171 -0
  8. package/dist/client.d.ts +45 -1
  9. package/dist/client.js +74 -1
  10. package/dist/contract/healthContract.js +31 -4
  11. package/dist/index.bundle.js +820 -142
  12. package/dist/index.js +7 -0
  13. package/dist/manifest.d.ts +14 -2
  14. package/dist/manifest.js +31 -9
  15. package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
  16. package/dist/public-marketplace/README.md +23 -0
  17. package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
  18. package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
  19. package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
  20. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
  21. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
  22. package/dist/remote/consent.d.ts +10 -2
  23. package/dist/remote/consent.js +15 -6
  24. package/dist/remote/consentMessages.d.ts +4 -1
  25. package/dist/remote/consentMessages.js +9 -6
  26. package/dist/remote/httpServer.js +2 -2
  27. package/dist/remote/oauth.js +9 -9
  28. package/dist/scaffold.d.ts +68 -6
  29. package/dist/scaffold.js +424 -97
  30. package/dist/server.js +175 -18
  31. package/dist/version.d.ts +1 -1
  32. package/dist/version.js +1 -1
  33. package/examples/app.appilot.json +212 -0
  34. package/mcpb/manifest.json +117 -21
  35. package/package.json +5 -3
  36. package/skills/app-configurator/SKILL.md +61 -19
package/dist/server.js CHANGED
@@ -19,7 +19,7 @@ import { inspectPage } from './inspect.js';
19
19
  import { runHealthContract } from './contract/healthContract.js';
20
20
  import { soakSelectors } from './soak.js';
21
21
  import { applyManifest, parseManifest, planManifest } from './manifest.js';
22
- import { scaffoldIntegration, scaffoldAgentFirst } from './scaffold.js';
22
+ import { SCAFFOLD_FRAMEWORKS, scaffoldIntegration, scaffoldAgentFirst, integrationSnippet } from './scaffold.js';
23
23
  import { verifyIntegration } from './verify.js';
24
24
  import { redactForTransport, refuseSecretOverRemote } from './redaction.js';
25
25
  /**
@@ -43,7 +43,9 @@ const DEFAULT_WIDGET_SCRIPT_URL = 'https://cdn.appilot.space/widget/v1/appilot.e
43
43
  */
44
44
  const SERVER_INSTRUCTIONS = `Audit, extend and fix an Appilot app's content-model configuration, and build a new capability so the agent can operate it.
45
45
 
46
- Work in this order: capabilities (what this instance supports, and the closed vocabularies its entities accept; on-premise trails cloud), read_config, validate_config, then report the findings to the user in plain language, ranked critical to low, each with its concrete fix. Do not paste raw tool output at them.
46
+ For a new app, work in this order: whoami, then create_app (on bare localhost pass isTest, which mints a wk_test_ key), then scaffold_integration for the host's framework, then verify_integration against the running page, then configure the content model.
47
+
48
+ To audit an existing one, work in this order: capabilities (what this instance supports, and the closed vocabularies its entities accept; on-premise trails cloud), read_config, validate_config, then report the findings to the user in plain language, ranked critical to low, each with its concrete fix. Do not paste raw tool output at them.
47
49
 
48
50
  If read_config returns a gaps array, an entity could not be read. Say so and stop treating that entity as empty: every lint over it silently passed.
49
51
 
@@ -58,6 +60,20 @@ Three things to get right. A widget secret belongs in the server environment and
58
60
  If something is missing or broken in Appilot itself, report_feedback records it. Say plainly what it does: the report is read, and a reply is part of a support plan rather than something promised here. Never put configuration contents, knowledge bodies or secrets in a report, and show the user the exact text first.
59
61
 
60
62
  Writing needs a config:write service token, reading needs config:read, provisioning needs provision:write, reporting needs feedback:write. A scope refusal means the user should reconnect with a token carrying that scope, not that you should find another route.`;
63
+ /**
64
+ * Where each scope comes from, appended to the refusal.
65
+ *
66
+ * A refusal that names the missing scope and stops leaves the reader one
67
+ * question short of acting: who can widen it. For provisioning the answer is
68
+ * not "you", which is exactly the case the configurator meets, so the refusal
69
+ * says who to ask and what the preset is called on the screen they will open.
70
+ */
71
+ const HOW_TO_GRANT = {
72
+ 'config:read': 'config:read is on every Service tokens preset in the Backoffice, "Inspect only" included.',
73
+ 'config:write': 'config:write comes from the Backoffice Service tokens preset "Edit configuration".',
74
+ 'provision:write': 'provision:write is granted by an organization administrator in the Backoffice, under Service tokens with the "Set up integrations" preset, or through the account approval screen. A configurator who is not an administrator cannot mint it and has to ask one.',
75
+ 'feedback:write': 'feedback:write is a Backoffice Service tokens option, and it is the only scope that sends anything out of the organization.',
76
+ };
61
77
  export function createAppilotServer(conn) {
62
78
  const client = new AppilotClient(conn);
63
79
  function text(value) {
@@ -85,7 +101,8 @@ export function createAppilotServer(conn) {
85
101
  if (!granted || granted.includes(scope))
86
102
  return null;
87
103
  return errorText(new Error(`This connection was granted ${granted.length ? granted.join(', ') : 'no scopes'}, which does not include ${scope}. ` +
88
- 'Reconnect and approve that scope, using a service token that carries it.'));
104
+ 'Reconnect and approve that scope, using a service token that carries it. ' +
105
+ HOW_TO_GRANT[scope]));
89
106
  }
90
107
  function resolveAppId(appId) {
91
108
  const id = appId ?? conn.defaultAppId;
@@ -365,16 +382,17 @@ export function createAppilotServer(conn) {
365
382
  });
366
383
  server.registerTool('create_app', {
367
384
  title: 'Create an app, its domains, and a widget key',
368
- description: 'Provision an Appilot app in one call: the app, the domains it runs on, and optionally a widget key, plus the exact script tag and boot snippet to paste into the host application. Idempotent: re-running converges on the existing app rather than creating a second one. Pass dryRun to preview. The widget key and its secret are returned EXACTLY ONCE, at creation; store the secret in the host backend only. Requires a provision:write service token.',
385
+ description: 'Provision an Appilot app in one call: the app, the domains it runs on, and optionally a widget key, plus the exact script tag and boot snippet to paste into the host application. Idempotent: re-running converges on the existing app rather than creating a second one. Pass dryRun to preview. The widget key and its secret are returned EXACTLY ONCE, at creation; store the secret in the host backend only. Working on your own machine: do NOT pass localhost or 127.0.0.1 as a domain, because they name every developer\'s machine and are refused. Either run the app on a hostname that resolves to 127.0.0.1 (myapp.lvh.me) and register THAT, which gives the turn full app context and needs no key, or pass isTest true with no domains for a loopback-bound wk_test_ key, which gives the tenant and the user and no app context. Requires a provision:write service token.',
369
386
  inputSchema: {
370
387
  name: z.string().min(1),
371
388
  description: z.string().optional(),
372
389
  domains: z.array(z.string()).optional(),
373
390
  widgetKeyName: z.string().optional(),
374
- isTestKey: z.boolean().optional(),
391
+ isTest: z.boolean().optional(),
392
+ allowedDomains: z.array(z.string()).optional(),
375
393
  dryRun: z.boolean().optional(),
376
394
  },
377
- }, async ({ name, description, domains, widgetKeyName, isTestKey, dryRun }) => {
395
+ }, async ({ name, description, domains, widgetKeyName, isTest, allowedDomains, dryRun }) => {
378
396
  const refusal = scopeRefusal('provision:write');
379
397
  if (refusal)
380
398
  return refusal;
@@ -382,8 +400,8 @@ export function createAppilotServer(conn) {
382
400
  const result = await client.provisionApp({
383
401
  app: { name, description },
384
402
  domains: (domains ?? []).map(domain => ({ domain })),
385
- widgetKey: widgetKeyName || isTestKey !== undefined
386
- ? { name: widgetKeyName, isTest: isTestKey }
403
+ widgetKey: widgetKeyName || isTest !== undefined || allowedDomains
404
+ ? { name: widgetKeyName, isTest, allowedDomains }
387
405
  : undefined,
388
406
  dryRun: dryRun === true,
389
407
  });
@@ -393,9 +411,135 @@ export function createAppilotServer(conn) {
393
411
  return errorText(err);
394
412
  }
395
413
  });
414
+ server.registerTool('list_apps', {
415
+ title: 'List the apps this organization has provisioned',
416
+ description: 'The apps this credential reaches, with each app\'s registered domains and their verification status. Call it before create_app so "which apps do I have" has an answer, and to find the app id and the domain id every other provisioning tool takes. Carries no secret. Reads the provisioning surface, so it needs a provision:write service token even though it writes nothing.',
417
+ inputSchema: {},
418
+ }, async () => {
419
+ const refusal = scopeRefusal('provision:write');
420
+ if (refusal)
421
+ return refusal;
422
+ try {
423
+ return text(await client.listProvisioned());
424
+ }
425
+ catch (err) {
426
+ return errorText(err);
427
+ }
428
+ });
429
+ server.registerTool('list_widget_keys', {
430
+ title: 'List the widget keys this organization holds',
431
+ description: 'The organization\'s widget keys by label, prefix, allowed domains and active state. No raw key and no secret: both are shown exactly once, at creation. Call it before create_app so a re-run recognises the key it already minted instead of asking for another, because every extra key is another live credential. Requires a provision:write service token.',
432
+ inputSchema: {},
433
+ }, async () => {
434
+ const refusal = scopeRefusal('provision:write');
435
+ if (refusal)
436
+ return refusal;
437
+ try {
438
+ return text(await client.listWidgetKeys());
439
+ }
440
+ catch (err) {
441
+ return errorText(err);
442
+ }
443
+ });
444
+ server.registerTool('verify_domain', {
445
+ title: 'Check or trigger a domain\'s DNS verification',
446
+ description: 'Where a domain\'s verification stands, and the exact TXT record it needs. Pass trigger to ask Appilot to look for the record now. A live widget key needs a verified domain, and this is what closes that loop: create_app reports the key as blocked and names the record, and this reports whether the record is visible yet. Requires a provision:write service token.',
447
+ inputSchema: {
448
+ domain: z.string().min(1),
449
+ appId: z.number().int().optional(),
450
+ trigger: z.boolean().optional(),
451
+ },
452
+ }, async ({ domain, appId, trigger }) => {
453
+ const refusal = scopeRefusal('provision:write');
454
+ if (refusal)
455
+ return refusal;
456
+ try {
457
+ const wanted = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/[/:].*$/, '');
458
+ const provisioned = await client.listProvisioned();
459
+ const scoped = appId != null ? provisioned.filter(a => Number(a.id) === appId) : provisioned;
460
+ const owner = scoped.find(a => a.domains.some(d => String(d.domain).toLowerCase() === wanted));
461
+ const row = owner?.domains.find(d => String(d.domain).toLowerCase() === wanted);
462
+ if (!owner || !row) {
463
+ return errorText(new Error(`${wanted} is not a registered domain of ${appId != null ? `app ${appId}` : 'any app in this organization'}. ` +
464
+ 'list_apps shows what is registered, and create_app adds a domain. A loopback name (localhost, 127.0.0.1) is never registrable: run the app on a hostname that resolves to 127.0.0.1 instead.'));
465
+ }
466
+ const result = {
467
+ app: { id: owner.id, name: owner.name },
468
+ domain: row.domain,
469
+ domainId: row.id,
470
+ verificationStatus: row.verification_status,
471
+ };
472
+ // The TXT record lives on the apps router, which authenticates an
473
+ // organization session rather than a service token. When that is
474
+ // refused, the provisioning dry run answers the same question, so the
475
+ // tool degrades to a second source instead of to nothing.
476
+ try {
477
+ result.record = await client.domainVerification(Number(row.id));
478
+ }
479
+ catch {
480
+ try {
481
+ const preview = await client.provisionApp({
482
+ app: { name: owner.name },
483
+ domains: [{ domain: row.domain }],
484
+ dryRun: true,
485
+ });
486
+ const previewed = preview.domains.find(d => d.domain.toLowerCase() === wanted);
487
+ if (previewed?.dns_record_name) {
488
+ result.record = {
489
+ dns_record_name: previewed.dns_record_name,
490
+ dns_record_value: previewed.dns_record_value,
491
+ verification_status: previewed.verification_status,
492
+ };
493
+ }
494
+ }
495
+ catch (err) {
496
+ result.recordUnavailable = err instanceof Error ? err.message : String(err);
497
+ }
498
+ }
499
+ if (trigger && row.verification_status !== 'verified') {
500
+ try {
501
+ result.checked = await client.triggerDomainVerification(Number(row.id));
502
+ }
503
+ catch (err) {
504
+ result.triggerRefused =
505
+ `${err instanceof Error ? err.message : String(err)} ` +
506
+ 'Triggering the check authenticates an organization session rather than a service token today, so run it from the Backoffice under Domains once the TXT record is published.';
507
+ }
508
+ }
509
+ return text(result);
510
+ }
511
+ catch (err) {
512
+ return errorText(err);
513
+ }
514
+ });
515
+ server.registerTool('integration_snippet', {
516
+ title: 'Get the script tag and boot call for an app that already exists',
517
+ description: 'The two forms of the widget boot for an app you already provisioned: the no-build script tag, and the bundled bootAppilotWidget call reading the key from the framework\'s public variable. Use it instead of re-running create_app, which is a provisioning write, when all you lost was the snippet. It composes the answer locally, writes nothing, and needs no scope. Pass the publishable widget key to have it appear in the script tag; the widget SECRET never belongs here.',
518
+ inputSchema: {
519
+ appId: z.number().int().optional(),
520
+ widgetKey: z.string().optional(),
521
+ widgetScriptUrl: z.string().optional(),
522
+ framework: z.enum(SCAFFOLD_FRAMEWORKS).optional(),
523
+ },
524
+ }, async ({ appId, widgetKey, widgetScriptUrl, framework }) => {
525
+ try {
526
+ return text({
527
+ appId: appId ?? conn.defaultAppId ?? null,
528
+ ...integrationSnippet({
529
+ widgetScriptUrl: widgetScriptUrl ?? DEFAULT_WIDGET_SCRIPT_URL,
530
+ apiUrl: conn.baseUrl || null,
531
+ widgetKey: widgetKey ?? null,
532
+ framework,
533
+ }),
534
+ });
535
+ }
536
+ catch (err) {
537
+ return errorText(err);
538
+ }
539
+ });
396
540
  server.registerTool('plan_manifest', {
397
541
  title: 'Preview an app manifest (writes nothing)',
398
- description: 'Diff an appilot.app-manifest against the live instance and return what would change: provisioning actions per app/domain/key, the config-bundle import diff, and the health findings over the resulting state. Writes nothing. Returns a planToken that apply_manifest requires, so an apply always follows a preview of the exact same manifest. Keep the manifest in the repository under version control.',
542
+ description: 'Diff an appilot.app-manifest against the live instance and return what would change: provisioning actions per app/domain/key, the config-bundle import diff, and the health findings over the resulting state. Writes nothing. Returns a planToken that apply_manifest requires, so an apply always follows a preview of the exact same manifest. Keep the manifest in the repository under version control. A complete example ships with this package at examples/app.appilot.json and is also in the docs. Which half is revisioned: the CONFIG half gets a pre_restore revision on every commit, so a wrong import is undone by restoring it; the PROVISIONING half is not revisioned, so a domain or a key it creates is undone by hand.',
399
543
  inputSchema: { manifest: z.union([z.record(z.any()), z.string()]) },
400
544
  }, async ({ manifest }) => {
401
545
  const refusal = scopeRefusal('config:read');
@@ -415,7 +559,7 @@ export function createAppilotServer(conn) {
415
559
  });
416
560
  server.registerTool('apply_manifest', {
417
561
  title: 'Apply a previously planned app manifest',
418
- description: 'Provision and configure an app from an appilot.app-manifest. Requires the planToken returned by plan_manifest for the SAME manifest: a mismatch means the manifest changed after it was previewed, and the apply is refused. Pass expectedCurrentHash from the plan so a concurrent config edit is a 409 rather than a silent overwrite. mode=replace makes the config match the bundle exactly, including deletions. Requires provision:write, and config:write when the manifest carries a config bundle.',
562
+ description: 'Provision and configure an app from an appilot.app-manifest. Requires the planToken returned by plan_manifest for the SAME manifest: a mismatch means the manifest changed after it was previewed, and the apply is refused. Pass expectedCurrentHash from the plan so a concurrent config edit is a 409 rather than a silent overwrite. mode=replace makes the config match the bundle exactly, including deletions. Needs config:write when the manifest carries a config bundle, and provision:write only when the provisioning half would actually change something: a manifest whose app, domains and keys already exist applies with config:write alone.',
419
563
  inputSchema: {
420
564
  manifest: z.union([z.record(z.any()), z.string()]),
421
565
  planToken: z.string(),
@@ -424,17 +568,26 @@ export function createAppilotServer(conn) {
424
568
  allowUnhealthy: z.boolean().optional(),
425
569
  },
426
570
  }, async ({ manifest, planToken, mode, expectedCurrentHash, allowUnhealthy }) => {
427
- // A manifest apply provisions AND writes configuration, so it needs both.
428
- const refusal = scopeRefusal('provision:write') ?? scopeRefusal('config:write');
429
- if (refusal)
430
- return refusal;
431
571
  try {
432
572
  const parsed = parseManifest(manifest);
573
+ // The config half always writes when the manifest carries one. The
574
+ // provisioning half is decided by the manifest rather than by the
575
+ // tool: applyManifest previews it and refuses only when it would
576
+ // actually change an app, a domain or a key. A CI token holding
577
+ // config:write alone keeps content in sync on an app a person
578
+ // already provisioned, which is the reason the scopes are separate.
579
+ if (parsed.config) {
580
+ const configRefusal = scopeRefusal('config:write');
581
+ if (configRefusal)
582
+ return configRefusal;
583
+ }
584
+ const provisionRefusal = scopeRefusal('provision:write');
433
585
  const result = await applyManifest(client, parsed, {
434
586
  planToken,
435
587
  mode,
436
588
  expectedCurrentHash,
437
589
  allowUnhealthy,
590
+ provisionRefusal: provisionRefusal ? provisionRefusal.content[0].text : null,
438
591
  });
439
592
  return text({ ...result, provisioning: redactForTransport(result.provisioning, conn.transport) });
440
593
  }
@@ -444,9 +597,9 @@ export function createAppilotServer(conn) {
444
597
  });
445
598
  server.registerTool('scaffold_integration', {
446
599
  title: 'Generate the host application integration code',
447
- description: 'Return the source a host application needs: the identity relay for its backend (the one security-critical piece, built on appilot-server), the widget boot call, and a client-action example. Returns file CONTENTS for you to write into the repository; this server never touches the filesystem. Pick the framework that matches the host.',
600
+ description: 'Return the source a host application needs: the identity relay for its backend (the one security-critical piece, built on appilot-server), the widget boot call, and a client-action example. Returns file CONTENTS for you to write into the repository; this server never touches the filesystem. Pick the framework that matches the host, or `other` when the backend is not Node (Django, Rails, PHP), which returns the raw exchange as curl plus a Python and a Ruby handler. The notes carry what local development needs, and the answer differs between a hostname you registered and bare localhost.',
448
601
  inputSchema: {
449
- framework: z.enum(['next', 'express', 'fastify', 'hono', 'remix', 'sveltekit']),
602
+ framework: z.enum(SCAFFOLD_FRAMEWORKS),
450
603
  widgetKey: z.string().optional(),
451
604
  widgetScriptUrl: z.string().optional(),
452
605
  idNamespace: z.string().optional(),
@@ -522,15 +675,17 @@ export function createAppilotServer(conn) {
522
675
  });
523
676
  server.registerTool('scaffold_agent_first', {
524
677
  title: 'Scaffold one capability so the agent can operate it',
525
- description: 'Return the four artifacts a capability needs to be agent-operable, agreeing with each other: the HTTP-proxy tool, the client action when the operation belongs in the page, the Action Plan that is the procedure, and the knowledge article that carries the meaning and not the steps. Also returns the order to create them in, which matters because a form cannot name controls that do not exist yet. `endpoint.path` is a path on the host origin, not an absolute URL. Use it when building a new agent-first app or making an existing feature reachable through the assistant.',
678
+ description: 'Return the four artifacts a capability needs to be agent-operable, agreeing with each other: the HTTP-proxy tool, the client action when the operation belongs in the page, the Action Plan that is the procedure, and the knowledge article that carries the meaning and not the steps. Also returns the order to create them in, which matters because a form cannot name controls that do not exist yet. `endpoint.path` is a path on the host origin, not an absolute URL. Pass `shape`: `create` is the open, fill and submit plan, `navigate` is one step to a screen, and `read` gets NO plan at all, because a plan whose only step opens a screen does nothing. Pass `clientSide` when the operation belongs in the page, and no HTTP-proxy tool is emitted. Use it when building a new agent-first app or making an existing feature reachable through the assistant.',
526
679
  inputSchema: {
527
680
  capability: z.string().min(1),
528
681
  slug: z.string().min(1),
529
682
  appId: z.number().int().optional(),
530
683
  endpoint: z.object({ method: z.string(), path: z.string() }).optional(),
531
684
  clientSide: z.boolean().optional(),
685
+ shape: z.enum(['create', 'navigate', 'read']).optional(),
686
+ viewPath: z.string().optional(),
532
687
  },
533
- }, async ({ capability, slug, appId, endpoint, clientSide }) => {
688
+ }, async ({ capability, slug, appId, endpoint, clientSide, shape, viewPath }) => {
534
689
  try {
535
690
  return text(scaffoldAgentFirst({
536
691
  capability,
@@ -538,6 +693,8 @@ export function createAppilotServer(conn) {
538
693
  appId: appId ?? conn.defaultAppId ?? null,
539
694
  endpoint: endpoint ?? null,
540
695
  clientSide,
696
+ shape,
697
+ viewPath,
541
698
  }));
542
699
  }
543
700
  catch (err) {
package/dist/version.d.ts CHANGED
@@ -11,4 +11,4 @@
11
11
  * hand-synced numbers drift and the drift shows up as a client reporting a
12
12
  * version the server does not have.
13
13
  */
14
- export declare const SERVER_VERSION = "0.2.1";
14
+ export declare const SERVER_VERSION = "0.3.0";
package/dist/version.js CHANGED
@@ -11,4 +11,4 @@
11
11
  * hand-synced numbers drift and the drift shows up as a client reporting a
12
12
  * version the server does not have.
13
13
  */
14
- export const SERVER_VERSION = '0.2.1';
14
+ export const SERVER_VERSION = '0.3.0';
@@ -0,0 +1,212 @@
1
+ {
2
+ "kind": "appilot.app-manifest",
3
+ "formatVersion": "1.0",
4
+ "app": {
5
+ "name": "Acme Booking",
6
+ "description": "The example manifest that ships with appilot-mcp. Copy it, change the names, keep the shape."
7
+ },
8
+ "domains": [
9
+ { "domain": "app.acme.com", "default_language": "en", "configured_languages": ["en", "de"] }
10
+ ],
11
+ "widgetKeys": [
12
+ { "name": "production", "allowedDomains": ["app.acme.com"] }
13
+ ],
14
+ "config": {
15
+ "kind": "appilot.config-bundle",
16
+ "formatVersion": "1.0",
17
+ "exportedAt": "2026-09-07T00:00:00.000Z",
18
+ "source": { "appId": 0, "appSlug": "acme-booking", "orgId": null },
19
+ "locales": ["en", "de"],
20
+ "entities": {
21
+ "views": [
22
+ {
23
+ "domain": "app.acme.com",
24
+ "path": "bookings",
25
+ "slug": "bookings",
26
+ "entry_path": "/bookings",
27
+ "is_active": true,
28
+ "source_locale": "en",
29
+ "translations": {
30
+ "en": { "name": "Bookings", "description": "The list of bookings and the form that creates one." },
31
+ "de": { "name": "Buchungen", "description": "Die Liste der Buchungen und das Formular, das eine anlegt." }
32
+ },
33
+ "detection_rules": [
34
+ { "priority": 10, "type": "path_prefix", "config": { "value": "/bookings" }, "is_active": true }
35
+ ]
36
+ }
37
+ ],
38
+ "controls": [
39
+ {
40
+ "semantic_id": "btn-create-booking",
41
+ "locator": "[data-testid=\"new-booking\"]",
42
+ "locator_type": "class_text",
43
+ "llm_hint": "Opens the booking form.",
44
+ "view_path": "bookings",
45
+ "scope": "view",
46
+ "prerequisites": null,
47
+ "is_active": true,
48
+ "form": null,
49
+ "source_locale": "en",
50
+ "translations": {
51
+ "en": { "label": "New booking", "description": "Opens the form that creates a booking." },
52
+ "de": { "label": "Neue Buchung", "description": "Öffnet das Formular, das eine Buchung anlegt." }
53
+ }
54
+ },
55
+ {
56
+ "semantic_id": "field-booking-date",
57
+ "locator": "booking-date",
58
+ "locator_type": "id",
59
+ "llm_hint": "The day the booking is for.",
60
+ "view_path": "bookings",
61
+ "scope": "view",
62
+ "prerequisites": null,
63
+ "is_active": true,
64
+ "form": { "form": "form-create-booking", "order": 1 },
65
+ "source_locale": "en",
66
+ "translations": {
67
+ "en": { "label": "Date", "description": "The day the booking is for." },
68
+ "de": { "label": "Datum", "description": "Der Tag, für den gebucht wird." }
69
+ }
70
+ },
71
+ {
72
+ "semantic_id": "submit-create-booking",
73
+ "locator": "[data-testid=\"save-booking\"]",
74
+ "locator_type": "class_text",
75
+ "llm_hint": "Saves the booking.",
76
+ "view_path": "bookings",
77
+ "scope": "view",
78
+ "prerequisites": null,
79
+ "is_active": true,
80
+ "form": null,
81
+ "source_locale": "en",
82
+ "translations": {
83
+ "en": { "label": "Save", "description": "Saves the booking." },
84
+ "de": { "label": "Speichern", "description": "Speichert die Buchung." }
85
+ }
86
+ }
87
+ ],
88
+ "forms": [
89
+ {
90
+ "semantic_id": "form-create-booking",
91
+ "domain": "app.acme.com",
92
+ "llm_hint": "Creates one booking.",
93
+ "view_paths": ["bookings"],
94
+ "required_fields": ["field-booking-date"],
95
+ "submit_control": "submit-create-booking",
96
+ "entry_control": "btn-create-booking",
97
+ "visibility_rules": null,
98
+ "field_defaults": null,
99
+ "prerequisites": null,
100
+ "is_active": true,
101
+ "source_locale": "en",
102
+ "translations": {
103
+ "en": { "label": "New booking", "description": "Creates one booking." },
104
+ "de": { "label": "Neue Buchung", "description": "Legt eine Buchung an." }
105
+ }
106
+ }
107
+ ],
108
+ "tools": [
109
+ {
110
+ "tool_name": "create_booking",
111
+ "view": null,
112
+ "parameters": {
113
+ "type": "object",
114
+ "properties": { "date": { "type": "string", "description": "ISO date the booking is for." } },
115
+ "required": ["date"]
116
+ },
117
+ "runtime_spec": {
118
+ "kind": "http_proxy",
119
+ "method": "POST",
120
+ "path_template": "/api/bookings",
121
+ "body_template": { "date": "{{date}}" }
122
+ },
123
+ "auth_header_name": "Authorization",
124
+ "source_locale": "en",
125
+ "translations": {
126
+ "en": { "title": "Create a booking", "description": "Use when the user wants to create a booking." },
127
+ "de": { "title": "Buchung anlegen", "description": "Verwenden, wenn die Person eine Buchung anlegen möchte." }
128
+ },
129
+ "parameter_descriptions": {
130
+ "en": { "date": "ISO date the booking is for." },
131
+ "de": { "date": "ISO-Datum, für das gebucht wird." }
132
+ }
133
+ }
134
+ ],
135
+ "zones": [],
136
+ "action_plans": [
137
+ {
138
+ "semantic_id": "plan-create-booking",
139
+ "sections": [
140
+ {
141
+ "view_path": "bookings",
142
+ "steps": [
143
+ "Open the form with [New booking]({{click:btn-create-booking}}).",
144
+ "Fill in [the booking form]({{form:form-create-booking}}).",
145
+ "Submit it with [Save]({{click:submit-create-booking}})."
146
+ ]
147
+ }
148
+ ],
149
+ "form_values": { "form-create-booking": { "fields": [] } },
150
+ "provenance": "registry",
151
+ "is_active": true,
152
+ "source_locale": "en",
153
+ "translations": {
154
+ "en": {
155
+ "name": "Create a booking",
156
+ "description": "Use when the user wants to create a booking.",
157
+ "step_narratives": null
158
+ },
159
+ "de": {
160
+ "name": "Buchung anlegen",
161
+ "description": "Verwenden, wenn die Person eine Buchung anlegen möchte.",
162
+ "step_narratives": [[
163
+ "Öffne das Formular mit [Neue Buchung]({{click:btn-create-booking}}).",
164
+ "Fülle [das Buchungsformular]({{form:form-create-booking}}) aus.",
165
+ "Sende es mit [Speichern]({{click:submit-create-booking}}) ab."
166
+ ]]
167
+ }
168
+ }
169
+ }
170
+ ],
171
+ "knowledge_content": [
172
+ {
173
+ "group": "booking-rules",
174
+ "scope": "app_global",
175
+ "url_pattern": null,
176
+ "domain": "app.acme.com",
177
+ "visibility": "internal",
178
+ "source": "manual",
179
+ "is_active": true,
180
+ "source_locale": "en",
181
+ "bodies": [
182
+ {
183
+ "language": "en",
184
+ "title": "What a booking is",
185
+ "description": "The rules and vocabulary behind a booking.",
186
+ "notes": null,
187
+ "general_info": "A booking reserves one resource for one day. Bookings are made by the account holder and cannot start in the past. A booking that has already started can be cancelled but not moved.\n\nThe procedure for creating one lives in the plan-create-booking action plan, not here."
188
+ },
189
+ {
190
+ "language": "de",
191
+ "title": "Was eine Buchung ist",
192
+ "description": "Die Regeln und Begriffe hinter einer Buchung.",
193
+ "notes": null,
194
+ "general_info": "Eine Buchung reserviert eine Ressource für einen Tag. Buchungen legt die Kontoinhaberin an, und sie können nicht in der Vergangenheit beginnen. Eine bereits begonnene Buchung lässt sich stornieren, aber nicht verschieben.\n\nDas Vorgehen zum Anlegen steht im Action Plan plan-create-booking, nicht hier."
195
+ }
196
+ ],
197
+ "views": [{ "domain": "app.acme.com", "path": "bookings", "priority": 10 }]
198
+ }
199
+ ],
200
+ "session_templates": []
201
+ },
202
+ "secretRefs": [
203
+ {
204
+ "entity": "tools",
205
+ "id": "create_booking",
206
+ "secret": "tool_auth:create_booking",
207
+ "status": "needs-reconnect",
208
+ "action": "Open the tool in the Backoffice and paste the credential. Secrets never travel in a bundle."
209
+ }
210
+ ]
211
+ }
212
+ }