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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/LICENSE +15 -0
- package/README.md +133 -27
- package/dist/appilot-configurator.mcpb +0 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +173 -0
- package/dist/client.d.ts +45 -1
- package/dist/client.js +74 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +6 -0
- package/dist/contract/healthContract.js +31 -4
- package/dist/index.bundle.js +2111 -826
- package/dist/index.d.ts +7 -1
- package/dist/index.js +22 -4
- package/dist/manifest.d.ts +14 -2
- package/dist/manifest.js +31 -9
- package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
- package/dist/public-marketplace/README.md +23 -0
- package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
- package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
- package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
- package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
- package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
- package/dist/redaction.d.ts +51 -0
- package/dist/redaction.js +59 -0
- package/dist/remote/consent.d.ts +10 -2
- package/dist/remote/consent.js +16 -6
- package/dist/remote/consentMessages.d.ts +6 -1
- package/dist/remote/consentMessages.js +15 -6
- package/dist/remote/httpServer.d.ts +10 -0
- package/dist/remote/httpServer.js +126 -40
- package/dist/remote/oauth.d.ts +10 -1
- package/dist/remote/oauth.js +29 -11
- package/dist/scaffold.d.ts +68 -6
- package/dist/scaffold.js +424 -97
- package/dist/server.js +175 -18
- package/dist/userClient.d.ts +213 -0
- package/dist/userClient.js +400 -0
- package/dist/userServer.d.ts +47 -0
- package/dist/userServer.js +248 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/examples/app.appilot.json +212 -0
- package/mcpb/manifest.json +117 -21
- package/package.json +5 -3
- 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
|
-
|
|
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
|
-
|
|
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,
|
|
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 ||
|
|
386
|
-
? { name: widgetKeyName, isTest
|
|
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.
|
|
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(
|
|
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) {
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client over the Appilot runtime routes (`/agent/runtime/*`), used by the
|
|
3
|
+
* Appilot connector: the surface an end user operates their own app through,
|
|
4
|
+
* from whatever assistant they already pay for.
|
|
5
|
+
*
|
|
6
|
+
* The credential is a user session, not a service token. It authenticates the
|
|
7
|
+
* way the extension does, so the connector can never exceed what the person
|
|
8
|
+
* themselves can do, and it can never write configuration: that is Studio, on a
|
|
9
|
+
* different mount with a different scope (see `client.ts`).
|
|
10
|
+
*
|
|
11
|
+
* Two rules about what this client does with an answer.
|
|
12
|
+
*
|
|
13
|
+
* Page-derived content passes through VERBATIM. `page/read` and `page/find`
|
|
14
|
+
* answer with one `page_content` string: the DOM tool's result serialized and
|
|
15
|
+
* wrapped in the backend's delimited untrusted-content block. Nothing here opens
|
|
16
|
+
* that block. The delimiters are a prompt-injection defence that works only
|
|
17
|
+
* while they are still around the content, the model reads the JSON inside them
|
|
18
|
+
* perfectly well, and a client that reassembled structure out of the block would
|
|
19
|
+
* be a second parser of a hostile string.
|
|
20
|
+
*
|
|
21
|
+
* Everything that is NOT page-derived is read field by field, and never invented.
|
|
22
|
+
* A missing field does not throw: an answer the connector cannot parse must not
|
|
23
|
+
* take the conversation down. A failed read never becomes an empty success:
|
|
24
|
+
* `configured` stays null when the instance did not say, an absent plan list is
|
|
25
|
+
* not an empty one, and a refusal comes back as a refusal carrying the guidance
|
|
26
|
+
* the backend wrote for the person.
|
|
27
|
+
*
|
|
28
|
+
* Contract: docs/architecture/appilot-runtime-connector.md.
|
|
29
|
+
* Routes: packages/services/backend/src/routes/agentRuntime.ts.
|
|
30
|
+
* Channel: docs/architecture/agent-bridge.md.
|
|
31
|
+
*/
|
|
32
|
+
import type { AppilotConnection } from './config.js';
|
|
33
|
+
/**
|
|
34
|
+
* What to say when a refusal arrives without guidance of its own.
|
|
35
|
+
*
|
|
36
|
+
* The routes send `error.guidance` from the bridge's failure vocabulary and
|
|
37
|
+
* that is what the assistant repeats, so this map is the fallback for a body
|
|
38
|
+
* that carries none. `NO_BRIDGE` is the one that matters most: a caller that
|
|
39
|
+
* turns it into a guess about the page is the failure the vocabulary exists to
|
|
40
|
+
* prevent.
|
|
41
|
+
*/
|
|
42
|
+
export declare const BRIDGE_REFUSALS: Record<string, string>;
|
|
43
|
+
/** A typed refusal from the runtime routes. Never a value a caller can mistake for data. */
|
|
44
|
+
export declare class RuntimeRefusalError extends Error {
|
|
45
|
+
readonly code: string;
|
|
46
|
+
readonly status: number;
|
|
47
|
+
constructor(message: string, code: string, status: number);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The steer that keeps an assistant from narrating a plan the person is already
|
|
51
|
+
* watching. `runtimeEnvelope` puts it on every response; see the connector spec,
|
|
52
|
+
* "Every result says whether it was shown".
|
|
53
|
+
*/
|
|
54
|
+
export interface Presentation {
|
|
55
|
+
shown_in_page: boolean;
|
|
56
|
+
instruction?: string;
|
|
57
|
+
}
|
|
58
|
+
/** What a visible result should have said, repeated when a result forgets to. */
|
|
59
|
+
export declare const SHOWN_IN_PAGE_INSTRUCTION = "This ran in the page the person is watching. Confirm in one sentence. Do not list the steps.";
|
|
60
|
+
/** Mode and its reason, exactly as the server derived them. Never inflated here. */
|
|
61
|
+
export interface ModeStamp {
|
|
62
|
+
mode: string | null;
|
|
63
|
+
mode_reason: string | null;
|
|
64
|
+
}
|
|
65
|
+
export interface RuntimeContextResult extends ModeStamp {
|
|
66
|
+
bridge_live: boolean | null;
|
|
67
|
+
surface: string | null;
|
|
68
|
+
url: string | null;
|
|
69
|
+
app: {
|
|
70
|
+
id: number | null;
|
|
71
|
+
name: string | null;
|
|
72
|
+
} | null;
|
|
73
|
+
view: {
|
|
74
|
+
view_path: string | null;
|
|
75
|
+
view_name: string | null;
|
|
76
|
+
} | null;
|
|
77
|
+
/** Null when the instance did not say. Never defaulted, in either direction. */
|
|
78
|
+
configured: boolean | null;
|
|
79
|
+
configured_reason: string | null;
|
|
80
|
+
/** Null when the instance did not answer with a plan list at all. */
|
|
81
|
+
plans: Record<string, unknown>[] | null;
|
|
82
|
+
presentation: Presentation | null;
|
|
83
|
+
notes: string[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A page read or find. `page_content` is the backend's delimited untrusted block
|
|
87
|
+
* and travels exactly as it arrived.
|
|
88
|
+
*/
|
|
89
|
+
export interface RuntimePageResult extends ModeStamp {
|
|
90
|
+
what?: string;
|
|
91
|
+
page_content: string | null;
|
|
92
|
+
elapsed_ms: number | null;
|
|
93
|
+
presentation: Presentation | null;
|
|
94
|
+
notes: string[];
|
|
95
|
+
}
|
|
96
|
+
export interface RuntimeHighlightResult extends ModeStamp {
|
|
97
|
+
target: Record<string, unknown> | null;
|
|
98
|
+
elapsed_ms: number | null;
|
|
99
|
+
presentation: Presentation | null;
|
|
100
|
+
notes: string[];
|
|
101
|
+
}
|
|
102
|
+
export interface RuntimeKnowledgeResult extends ModeStamp {
|
|
103
|
+
/** Null when the instance did not answer with passages. Empty means it found none. */
|
|
104
|
+
passages: Record<string, unknown>[] | null;
|
|
105
|
+
citations: Record<string, unknown>[] | null;
|
|
106
|
+
configured: boolean | null;
|
|
107
|
+
configured_reason: string | null;
|
|
108
|
+
/** The server's own line for the person, on an app with no curated knowledge. */
|
|
109
|
+
guidance: string | null;
|
|
110
|
+
presentation: Presentation | null;
|
|
111
|
+
notes: string[];
|
|
112
|
+
}
|
|
113
|
+
export interface RuntimePlansResult extends ModeStamp {
|
|
114
|
+
plans: Record<string, unknown>[] | null;
|
|
115
|
+
configured: boolean | null;
|
|
116
|
+
view_path: string | null;
|
|
117
|
+
presentation: Presentation | null;
|
|
118
|
+
notes: string[];
|
|
119
|
+
}
|
|
120
|
+
export interface RuntimePlanRunResult extends ModeStamp {
|
|
121
|
+
plan_handle: string | null;
|
|
122
|
+
plan_id: string | null;
|
|
123
|
+
plan_name: string | null;
|
|
124
|
+
interaction_id: number | null;
|
|
125
|
+
pace: string | null;
|
|
126
|
+
state: string | null;
|
|
127
|
+
/** Above zero means the assembler refused part of what was asked. */
|
|
128
|
+
steps_dropped: number | null;
|
|
129
|
+
presentation: Presentation | null;
|
|
130
|
+
notes: string[];
|
|
131
|
+
}
|
|
132
|
+
export interface RuntimePlanStatusResult extends ModeStamp {
|
|
133
|
+
plan_handle: string | null;
|
|
134
|
+
plan_id: string | null;
|
|
135
|
+
plan_name: string | null;
|
|
136
|
+
pace: string | null;
|
|
137
|
+
state: string | null;
|
|
138
|
+
step_index: number | null;
|
|
139
|
+
total_steps: number | null;
|
|
140
|
+
detail: string | null;
|
|
141
|
+
updated_at: number | null;
|
|
142
|
+
/** True when the call held for a transition rather than reading the current state. */
|
|
143
|
+
waited: boolean;
|
|
144
|
+
presentation: Presentation | null;
|
|
145
|
+
notes: string[];
|
|
146
|
+
}
|
|
147
|
+
/** The three paces the dock implements. There is no fourth, and a connector may not invent timings. */
|
|
148
|
+
export declare const PACES: readonly ["teach", "walk", "do"];
|
|
149
|
+
export type Pace = (typeof PACES)[number];
|
|
150
|
+
/** The three ways `POST /page/read` can look at the page, in the route's own words. */
|
|
151
|
+
export declare const PAGE_READS: readonly ["outline", "text", "form_state"];
|
|
152
|
+
export type PageRead = (typeof PAGE_READS)[number];
|
|
153
|
+
/** One field of a plan's form step, by the control's semantic id. */
|
|
154
|
+
export interface PlanFormValue {
|
|
155
|
+
control: string;
|
|
156
|
+
value: string;
|
|
157
|
+
}
|
|
158
|
+
export declare class AppilotRuntimeClient {
|
|
159
|
+
private readonly conn;
|
|
160
|
+
constructor(conn: AppilotConnection);
|
|
161
|
+
private request;
|
|
162
|
+
/** Where the person is, and what applies here. Carries no mode by design. */
|
|
163
|
+
context(): Promise<RuntimeContextResult>;
|
|
164
|
+
/**
|
|
165
|
+
* Outline, visible text in a region, or form state with validation errors.
|
|
166
|
+
*
|
|
167
|
+
* The answer's `page_content` is passed on untouched, delimiters and all.
|
|
168
|
+
*/
|
|
169
|
+
readPage(args: {
|
|
170
|
+
what: PageRead;
|
|
171
|
+
region_id?: string;
|
|
172
|
+
selector?: string;
|
|
173
|
+
form_id?: string;
|
|
174
|
+
max_chars?: number;
|
|
175
|
+
}): Promise<RuntimePageResult>;
|
|
176
|
+
/** Elements by role and accessible name, inside the same untrusted block. */
|
|
177
|
+
findOnPage(args: {
|
|
178
|
+
role?: string;
|
|
179
|
+
name?: string;
|
|
180
|
+
limit?: number;
|
|
181
|
+
}): Promise<RuntimePageResult>;
|
|
182
|
+
private pageResult;
|
|
183
|
+
/** Draw the locate overlay on a control, a zone or a ref. Visible work. */
|
|
184
|
+
highlight(args: {
|
|
185
|
+
control?: string;
|
|
186
|
+
zone?: string;
|
|
187
|
+
ref?: string;
|
|
188
|
+
}): Promise<RuntimeHighlightResult>;
|
|
189
|
+
/** Curated knowledge with resolved citations. Answers without a shared tab. */
|
|
190
|
+
searchKnowledge(args: {
|
|
191
|
+
query: string;
|
|
192
|
+
}): Promise<RuntimeKnowledgeResult>;
|
|
193
|
+
/** The curated procedures that apply to the current view. */
|
|
194
|
+
listPlans(): Promise<RuntimePlansResult>;
|
|
195
|
+
/**
|
|
196
|
+
* Assemble a curated plan and publish it to the page at a pace.
|
|
197
|
+
*
|
|
198
|
+
* `plan_id` is the authored id `list_plans` returns; there is no free-form
|
|
199
|
+
* goal, because an app with no curated plan for the task is refused rather
|
|
200
|
+
* than served an assembled guess.
|
|
201
|
+
*/
|
|
202
|
+
runPlan(args: {
|
|
203
|
+
plan_id: string;
|
|
204
|
+
pace?: Pace;
|
|
205
|
+
form_values?: PlanFormValue[];
|
|
206
|
+
}): Promise<RuntimePlanRunResult>;
|
|
207
|
+
/**
|
|
208
|
+
* State of a running plan. With `wait` the route holds up to 50 seconds for
|
|
209
|
+
* the next transition, which is how an assistant that cannot see the screen
|
|
210
|
+
* follows a plan without polling.
|
|
211
|
+
*/
|
|
212
|
+
planStatus(handle: string, wait?: boolean): Promise<RuntimePlanStatusResult>;
|
|
213
|
+
}
|