loopengine 0.1.98 → 0.1.99

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 (35) hide show
  1. package/dist/bin/ability-manager.d.ts +35 -2
  2. package/dist/bin/ability-manager.js +168 -35
  3. package/dist/core/ability-details.js +11 -0
  4. package/dist/core/mcp-abilities.d.ts +91 -0
  5. package/dist/core/mcp-abilities.js +400 -0
  6. package/dist/core/mcp-ability-generator.d.ts +59 -0
  7. package/dist/core/mcp-ability-generator.js +193 -0
  8. package/dist/core/mcp-apps.d.ts +95 -0
  9. package/dist/core/mcp-apps.js +254 -0
  10. package/dist/core/mcp-directory.d.ts +32 -0
  11. package/dist/core/mcp-directory.js +191 -0
  12. package/dist/core/run-agent.js +5 -2
  13. package/dist/core/system-agents/interviewer.d.ts +1 -1
  14. package/dist/core/system-agents/interviewer.js +1 -1
  15. package/dist/core/system-agents/templates.d.ts +11 -0
  16. package/dist/core/system-agents/templates.js +22 -16
  17. package/dist/core/system-agents/workflow-planner.d.ts +36 -3
  18. package/dist/core/system-agents/workflow-planner.js +23 -6
  19. package/dist/core/workspace-abilities.js +1 -1
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +3 -0
  22. package/dist/web/abilities-admin.d.ts +1 -1
  23. package/dist/web/abilities-admin.js +1 -1
  24. package/dist/web/agents-config-page.js +187 -11
  25. package/dist/web/console-shell.js +7 -5
  26. package/dist/web/dev-ui-styles.js +1 -1
  27. package/dist/web/global-config-page.js +293 -13
  28. package/dist/web/interview-admin.js +2 -0
  29. package/dist/web/templates-admin.d.ts +10 -2
  30. package/dist/web/templates-admin.js +36 -3
  31. package/dist/web/templates-page.js +1 -0
  32. package/dist/web/workflow-admin.d.ts +46 -0
  33. package/dist/web/workflow-admin.js +195 -3
  34. package/dist/web/workflow-page.js +33 -3
  35. package/package.json +10 -1
@@ -32,6 +32,7 @@ export const consoleSidebarHtml = `<aside class="le-sidebar" aria-label="Console
32
32
  <a class="le-find" href="/workflows/new" data-le-nav="workflows">Describe a workflow</a>
33
33
  <nav class="le-foot" aria-label="Settings">
34
34
  <a href="/config?section=models" data-le-nav="models">Models</a>
35
+ <a href="/config?section=apps" data-le-nav="apps">Apps</a>
35
36
  <a href="/config?section=abilities" data-le-nav="abilities">Abilities</a>
36
37
  <a href="/config?section=settings" data-le-nav="settings">Settings</a>
37
38
  <div class="le-who" id="leWho" hidden></div>
@@ -51,10 +52,11 @@ export const consoleShellScript = `<script>
51
52
  // after the list loads.
52
53
  var toSet = {};
53
54
  // Tools and Skills live under Advanced (an agent's own tools and skills,
54
- // Composio, subagents): what an agent can do is shown per ability in
55
- // Abilities. ?tab=tools and ?tab=skills still open Advanced.
55
+ // Composio, subagents): what an agent can do is shown per app and
56
+ // ability on the Apps tab (id 'abilities', so older links still work).
57
+ // ?tab=tools and ?tab=skills still open Advanced.
56
58
  var TABS = [
57
- ['overview', 'Overview'], ['chat', 'Chat'], ['abilities', 'Abilities'], ['actauth', 'Permissions'],
59
+ ['overview', 'Overview'], ['chat', 'Chat'], ['abilities', 'Apps'], ['actauth', 'Permissions'],
58
60
  ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['env', 'Environment'], ['advanced', 'Advanced']
59
61
  ];
60
62
 
@@ -170,7 +172,7 @@ export const consoleShellScript = `<script>
170
172
  }
171
173
 
172
174
  var section = params.get('section');
173
- var foot = page === 'config' ? (section === 'abilities' || section === 'gateways' ? 'abilities' : section === 'settings' ? 'settings' : 'models') : page === 'interview' || page === 'templates' ? page : '';
175
+ var foot = page === 'config' ? (section === 'abilities' || section === 'gateways' ? 'abilities' : section === 'apps' ? 'apps' : section === 'settings' ? 'settings' : 'models') : page === 'interview' || page === 'templates' ? page : '';
174
176
  var footLinks = document.querySelectorAll('[data-le-nav]');
175
177
  for (var i = 0; i < footLinks.length; i++) {
176
178
  if (footLinks[i].getAttribute('data-le-nav') === foot) footLinks[i].setAttribute('aria-current', 'page');
@@ -348,7 +350,7 @@ export const consoleShellScript = `<script>
348
350
  }
349
351
 
350
352
  // ---- Abilities: where one came from, and what it can do ----
351
- // Used by the Abilities page and each agent's Abilities tab.
353
+ // Used by the Abilities page and each agent's Apps tab.
352
354
 
353
355
  var DECISION_LABELS = { allow: 'Allow', ask: 'Ask me', deny: 'Never' };
354
356
 
@@ -108,7 +108,7 @@ export const devUiCss = `
108
108
  .le-pill.ask, .le-pill.warn { background: var(--warn-soft); color: var(--warn); }
109
109
  .le-pill.deny, .le-pill.bad { background: var(--bad-soft); color: var(--bad); }
110
110
  /* An ability's details (leShell.toggleAbilityDetails), on the Abilities
111
- page and each agent's Abilities tab. */
111
+ page and each agent's Apps tab. */
112
112
  .ability-details-row > td { padding: 0 10px 14px; border-top: 0; background: transparent; }
113
113
  .ability-details { border: 1px solid var(--accent); border-left-width: 4px; border-radius: var(--radius); background: var(--accent-soft); padding: 12px 16px 14px; }
114
114
  .ability-details-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 16px; margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid var(--line); }
@@ -78,6 +78,32 @@ export const globalConfigPageHtml = `<!doctype html>
78
78
  form.key-form button { font-size: 12px; }
79
79
  .key-msg { font-size: 11px; margin-top: 4px; }
80
80
  .key-msg.bad { color: var(--bad); }
81
+ .app-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }
82
+ .app-tile { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px; display: grid; gap: 8px; align-content: start; background: var(--bg); }
83
+ .app-head { display: flex; gap: 10px; align-items: center; min-width: 0; }
84
+ .app-head strong { overflow-wrap: anywhere; }
85
+ .app-icon { width: 32px; height: 32px; border-radius: 8px; flex: none; object-fit: contain; background: var(--surface); border: 1px solid var(--line); }
86
+ .app-letter { width: 32px; height: 32px; border-radius: 8px; flex: none; display: grid; place-items: center; font-weight: 700; font-size: 15px; background: var(--accent-soft, var(--surface)); color: var(--accent); border: 1px solid var(--line); }
87
+ .app-desc { font-size: 12.5px; color: var(--ink-muted); margin: 0; }
88
+ .app-foot { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
89
+ .app-form { display: grid; gap: 6px; }
90
+ .app-form input { font: inherit; font-size: 12px; font-family: ui-monospace, monospace; padding: 5px 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); width: 100%; box-sizing: border-box; }
91
+ .app-row { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; padding: 10px 12px; border-top: 1px solid var(--line); }
92
+ .app-row:first-child { border-top: 0; }
93
+ .app-row-main { flex: 1; min-width: 200px; display: grid; gap: 3px; }
94
+ .app-list { border: 1px solid var(--line); border-radius: var(--radius); }
95
+ .app-tools { display: grid; gap: 8px; margin-top: 8px; font-size: 12.5px; }
96
+ .app-tools input[type=search] { font: inherit; font-size: 12px; padding: 5px 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); max-width: 280px; }
97
+ .app-tool-list { display: grid; gap: 2px; max-height: 360px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); padding: 4px; }
98
+ .app-tool { display: flex; gap: 8px; align-items: flex-start; padding: 5px 6px; border-radius: 6px; cursor: pointer; }
99
+ .app-tool:hover { background: var(--surface); }
100
+ .app-tool input { margin-top: 3px; }
101
+ .app-tool code { overflow-wrap: anywhere; }
102
+ .app-tool-desc { display: block; }
103
+ .app-decision { font: inherit; font-size: 11.5px; padding: 1px 4px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); }
104
+ .app-cats { display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 10px; }
105
+ .app-cats button { font-size: 12px; }
106
+ .app-cats button.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
81
107
  </style>
82
108
  </head>
83
109
  <body data-le-page="config">
@@ -89,9 +115,14 @@ ${consoleSidebarHtml}
89
115
  <div><h1>Models</h1><p>API keys for each model provider, and which agents use each one. A saved key is stored on this server and never shown again.</p></div></div></div>
90
116
  <div class="le-scroll"><div class="le-page" style="display:block"><div id="modelsContent"><p class="hint">Loading&hellip;</p></div></div></div>
91
117
  </div>
118
+ <div class="section-panel" data-section-panel="apps" style="display:none">
119
+ <div class="le-head plain"><div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">&#9776;</button>
120
+ <div><h1>Apps</h1><p>Connect the apps your agents work in. All of a connected app&rsquo;s tools become available to the workspace, each with a default permission; each agent then chooses which tools it gets, from its Apps tab.</p></div></div></div>
121
+ <div class="le-scroll"><div class="le-page" style="display:block"><div id="appsContent"><p class="hint">Loading&hellip;</p></div></div></div>
122
+ </div>
92
123
  <div class="section-panel" data-section-panel="abilities" style="display:none">
93
124
  <div class="le-head plain"><div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">&#9776;</button>
94
- <div><h1>Abilities</h1><p>How agents reach your apps, such as Gmail, Shopify or GitHub. An ability installed into an agent adds its tools, skills and permission rules.</p></div></div></div>
125
+ <div><h1>Abilities</h1><p>What agents can do beyond your connected apps: search the web, read documents, schedule work, or your own code. An ability added to an agent brings its tools, skills and permission rules.</p></div></div></div>
95
126
  <div class="le-scroll"><div class="le-page" style="display:block"><div id="abilitiesContent"><p class="hint">Loading&hellip;</p></div></div></div>
96
127
  </div>
97
128
  <div class="section-panel" data-section-panel="settings" style="display:none">
@@ -221,12 +252,12 @@ ${consoleShellScript}
221
252
 
222
253
  // ---- Abilities section ----
223
254
  // How agents reach outside apps. Public abilities (npm) are browsed
224
- // here and installed from an agent's own Abilities tab. A private one
255
+ // here and installed from an agent's own Apps tab. A private one
225
256
  // (from the sources in Settings) is installed here into the workspace
226
257
  // (core/workspace-abilities.ts), then added to any agent from its tab.
227
258
 
228
259
  // An ability's details (tools with their permissions, skills, settings)
229
- // come from the console shell, shared with each agent's Abilities tab.
260
+ // come from the console shell, shared with each agent's Apps tab.
230
261
  var toggleAbilityDetails = leShell.toggleAbilityDetails;
231
262
 
232
263
  function detailsButton(spec) {
@@ -240,13 +271,13 @@ ${consoleShellScript}
240
271
  function renderAbilities() {
241
272
  var content = document.getElementById('abilitiesContent');
242
273
  content.innerHTML = '<section>' +
243
- '<h4>Public <span class="hint">npm packages tagged loopengine-ability; add one to an agent from its Abilities tab</span></h4>' +
274
+ '<h4>Public <span class="hint">npm packages tagged loopengine-ability; add one to an agent from its Apps tab</span></h4>' +
244
275
  '<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
245
276
  '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search public abilities" style="flex:1">' +
246
277
  '<button type="submit">Search</button>' +
247
278
  '</form>' +
248
279
  '<div id="abilitySearchResults"><p class="hint">Loading&hellip;</p></div>' +
249
- '<h4 id="private-abilities">Private <span class="hint">your own, from the sources in <a href="/config?section=settings">Settings</a>; install one into the workspace, then add it to agents from their Abilities tab</span></h4>' +
280
+ '<h4 id="private-abilities">Private <span class="hint">your own, from the sources in <a href="/config?section=settings">Settings</a>; install one into the workspace, then add it to agents from their Apps tab</span></h4>' +
250
281
  '<p id="abilityInstallStatus" class="hint"></p>' +
251
282
  '<div id="workspaceAbilities"></div>' +
252
283
  '<div id="privateAbilities"><p class="hint">Loading&hellip;</p></div>' +
@@ -296,7 +327,7 @@ ${consoleShellScript}
296
327
  .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
297
328
  .then(function (r) {
298
329
  if (!r.ok) throw new Error(r.body.error || 'upload failed');
299
- status.textContent = 'Uploaded ' + label + ' into the workspace. Add it to an agent from the agent' + String.fromCharCode(8217) + 's Abilities tab.';
330
+ status.textContent = 'Uploaded ' + label + ' into the workspace. Add it to an agent from the agent' + String.fromCharCode(8217) + 's Apps tab.';
300
331
  workspaceAbilities = r.body.abilities || [];
301
332
  renderWorkspaceAbilities();
302
333
  renderPrivateAbilities();
@@ -367,14 +398,17 @@ ${consoleShellScript}
367
398
  function renderWorkspaceAbilities() {
368
399
  var el = document.getElementById('workspaceAbilities');
369
400
  if (!el || !workspaceAbilities) return;
370
- el.innerHTML = workspaceAbilities.length
371
- ? '<div class="source"><p style="margin:0 0 8px"><strong>In this workspace</strong> <span class="hint">add these to an agent from its Abilities tab</span></p>' +
372
- '<table><thead><tr><th>Ability</th><th>From</th><th></th></tr></thead><tbody>' + workspaceAbilities.map(function (a) {
401
+ // Abilities made from a connected app are listed under Apps.
402
+ var fromApps = workspaceAbilities.filter(function (a) { return a.app || a.source.indexOf('mcp:') === 0; }).length;
403
+ var own = workspaceAbilities.filter(function (a) { return !a.app && a.source.indexOf('mcp:') !== 0; });
404
+ el.innerHTML = (fromApps ? '<p class="hint" style="margin:0 0 8px">' + fromApps + ' connected app' + (fromApps === 1 ? '' : 's') + ' also in this workspace: see <a href="/config?section=apps">Apps</a>.</p>' : '') + (own.length
405
+ ? '<div class="source"><p style="margin:0 0 8px"><strong>In this workspace</strong> <span class="hint">add these to an agent from its Apps tab</span></p>' +
406
+ '<table><thead><tr><th>Ability</th><th>From</th><th></th></tr></thead><tbody>' + own.map(function (a) {
373
407
  return '<tr><td><code>' + escapeHtml(a.name) + '</code>' + (a.version ? ' <span class="hint">' + escapeHtml(a.version) + '</span>' : '') + '</td>' +
374
408
  '<td><span class="hint">' + escapeHtml(a.source.indexOf('upload:') === 0 ? 'Uploaded ' + a.source.slice(7) : a.source) + '</span></td>' +
375
409
  '<td style="white-space:nowrap">' + detailsButton(a.spec) + ' <button type="button" class="delete-btn workspace-remove-btn" data-name="' + escapeHtml(a.name) + '">Remove</button></td></tr>';
376
410
  }).join('') + '</tbody></table></div>'
377
- : '';
411
+ : '');
378
412
  }
379
413
 
380
414
  // Each source's abilities: Install puts one into the workspace; one
@@ -408,7 +442,7 @@ ${consoleShellScript}
408
442
  }).join('');
409
443
  }
410
444
 
411
- // Linked from an agent's Abilities tab (/config?section=abilities#private-abilities):
445
+ // Linked from an agent's Apps tab (/config?section=abilities#private-abilities):
412
446
  // once every list has loaded, so the public results don't push it down.
413
447
  var scrolledToPrivate = false;
414
448
  function scrollToPrivateIfLinked() {
@@ -472,7 +506,7 @@ ${consoleShellScript}
472
506
  .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
473
507
  .then(function (r) {
474
508
  if (!r.ok) throw new Error(r.body.error || 'request failed');
475
- status.textContent = 'Installed ' + spec + ' into the workspace. Add it to an agent from the agent' + String.fromCharCode(8217) + 's Abilities tab.';
509
+ status.textContent = 'Installed ' + spec + ' into the workspace. Add it to an agent from the agent' + String.fromCharCode(8217) + 's Apps tab.';
476
510
  workspaceAbilities = r.body.abilities || [];
477
511
  renderWorkspaceAbilities();
478
512
  renderPrivateAbilities();
@@ -503,6 +537,251 @@ ${consoleShellScript}
503
537
  loadedSections.abilities = true;
504
538
  }
505
539
 
540
+ // ---- Apps section ----
541
+ // Popular apps' own MCP servers (core/mcp-apps.ts) and the connected
542
+ // ones. Connecting makes a workspace ability from the server; agents
543
+ // add it from their Apps tab.
544
+
545
+ var apps = null;
546
+ var appCategory = 'All';
547
+ // Open tool panels, by ability: { tools, filter, error, busy }.
548
+ var openTools = {};
549
+ var appSearch = { q: '', servers: null, error: '' };
550
+
551
+ function appLetter(title) {
552
+ return '<span class="app-letter" aria-hidden="true">' + escapeHtml((title || '?').charAt(0).toUpperCase()) + '</span>';
553
+ }
554
+ function appIcon(app) {
555
+ return app.icon
556
+ ? '<img class="app-icon" src="' + escapeHtml(app.icon) + '" alt="" data-title="' + escapeHtml(app.title || '') + '" loading="lazy" referrerpolicy="no-referrer">'
557
+ : appLetter(app.title);
558
+ }
559
+ // An icon that doesn't load becomes the app's first letter.
560
+ document.getElementById('appsContent').addEventListener('error', function (ev) {
561
+ var img = ev.target;
562
+ if (!img.classList || !img.classList.contains('app-icon')) return;
563
+ var holder = document.createElement('span');
564
+ holder.innerHTML = appLetter(img.dataset.title);
565
+ img.replaceWith(holder.firstChild);
566
+ }, true);
567
+
568
+ function connectForm(id) {
569
+ return '<form class="app-form" data-connect="' + escapeHtml(id) + '">' +
570
+ '<input type="password" name="key" autocomplete="off" placeholder="API key, if it needs one" aria-label="API key">' +
571
+ '<div class="app-foot"><button type="submit">Connect</button><button type="button" class="app-cancel">Cancel</button></div>' +
572
+ '<p class="hint app-msg" style="margin:0"></p></form>';
573
+ }
574
+
575
+ function renderApps() {
576
+ var el = document.getElementById('appsContent');
577
+ if (!apps) return;
578
+ var categories = ['All'];
579
+ apps.apps.forEach(function (a) { if (categories.indexOf(a.category) === -1) categories.push(a.category); });
580
+ var shown = apps.apps.filter(function (a) { return appCategory === 'All' || a.category === appCategory; });
581
+ el.innerHTML =
582
+ (apps.canConnect ? '' : '<p class="error">Connecting an app saves its key on this server, so it needs LOOPENGINE_ADMIN_AUTH set first.</p>') +
583
+ '<section><h3>Connected</h3>' + (apps.connected.length
584
+ ? '<div class="app-list">' + apps.connected.map(function (c) {
585
+ var used = c.agents.length ? 'Used by ' + c.agents.map(escapeHtml).join(', ') : 'Not added to an agent yet: add <code>' + escapeHtml(c.ability) + '</code> from an agent' + String.fromCharCode(8217) + 's Apps tab';
586
+ return '<div class="app-row"><div class="app-head">' + appIcon(findApp(c) || c) + '</div>' +
587
+ '<div class="app-row-main"><div><strong>' + escapeHtml(c.title) + '</strong> <span class="hint">' + c.tools.length + ' tool' + (c.tools.length === 1 ? '' : 's') + ' &middot; <code>' + escapeHtml(c.ability) + '</code></span></div>' +
588
+ '<div class="hint">' + used + '</div>' +
589
+ (c.keyEnv && !c.keySet ? '<div class="error" style="font-size:12px">Its key, ' + escapeHtml(c.keyEnv) + ', isn' + String.fromCharCode(8217) + 't set.</div>' : '') +
590
+ (openTools[c.ability] ? toolsPanel(c.ability) : '') +
591
+ '</div>' +
592
+ '<div class="app-foot"><button type="button" class="app-tools-btn" data-ability="' + escapeHtml(c.ability) + '">' + (openTools[c.ability] ? 'Hide tools' : 'Tools') + '</button> <button type="button" class="delete-btn app-disconnect" data-ability="' + escapeHtml(c.ability) + '">Disconnect</button></div></div>';
593
+ }).join('') + '</div>'
594
+ : '<p class="hint">No apps connected yet. Pick one below.</p>') + '</section>' +
595
+ '<section><h3>Popular apps</h3>' +
596
+ '<div class="app-cats">' + categories.map(function (c) { return '<button type="button" class="' + (c === appCategory ? 'active' : '') + '" data-app-cat="' + escapeHtml(c) + '">' + escapeHtml(c) + '</button>'; }).join('') + '</div>' +
597
+ '<div class="app-grid">' + shown.map(function (a) {
598
+ return '<div class="app-tile" data-app="' + escapeHtml(a.id) + '"><div class="app-head">' + appIcon(a) + '<div><strong>' + escapeHtml(a.title) + '</strong><div class="hint">' + escapeHtml(a.category) + '</div></div></div>' +
599
+ '<p class="app-desc">' + escapeHtml(a.description) + '</p>' +
600
+ '<div class="app-slot">' + (a.connected ? '<span class="badge badge-custom">Connected</span>' : '<button type="button" class="app-connect" data-id="' + escapeHtml(a.id) + '"' + (apps.canConnect ? '' : ' disabled') + '>Connect</button>') + '</div></div>';
601
+ }).join('') + '</div>' +
602
+ '<p class="hint">Each is the app' + String.fromCharCode(8217) + 's own MCP server. Apps that only offer a sign-in (OAuth) can' + String.fromCharCode(8217) + 't be connected yet; their Connect says so.</p>' +
603
+ '</section>' +
604
+ '<section><h3>More apps</h3>' +
605
+ '<form id="appSearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px"><input type="search" name="q" placeholder="Search the MCP Registry, e.g. calendly" aria-label="Search apps" style="flex:1" value="' + escapeHtml(appSearch.q) + '"><button type="submit">Search</button></form>' +
606
+ '<div id="appSearchResults">' + renderAppSearch() + '</div>' +
607
+ '</section>';
608
+ }
609
+
610
+ var DECISION_LABEL = { allow: 'Allow', ask: 'Ask me', deny: 'Never' };
611
+
612
+ function toolsPanel(ability) {
613
+ var panel = openTools[ability];
614
+ if (panel.error) return '<div class="app-tools"><p class="error" style="margin:0">' + escapeHtml(panel.error) + '</p></div>';
615
+ if (!panel.tools) return '<div class="app-tools"><p class="hint" style="margin:0">Asking the app for its tools' + String.fromCharCode(8230) + '</p></div>';
616
+ var used = panel.tools.filter(function (t) { return t.usedBy.length; }).length;
617
+ var f = (panel.filter || '').toLowerCase();
618
+ var shown = panel.tools.filter(function (t) { return !f || (t.remote + ' ' + t.description).toLowerCase().indexOf(f) !== -1; });
619
+ return '<div class="app-tools">' +
620
+ '<p class="hint" style="margin:0">All ' + panel.tools.length + ' tools are available to this workspace; ' + used + ' are used by an agent. Agents choose their own tools and permissions when you add the app to them (their Apps tab). The permission here is the default an agent starts from: Allow runs it, Ask me waits for your OK, Never blocks it.</p>' +
621
+ '<div class="app-foot">' + (panel.tools.length > 12 ? '<input type="search" class="app-tools-filter" data-ability="' + escapeHtml(ability) + '" placeholder="Filter tools" value="' + escapeHtml(panel.filter || '') + '" aria-label="Filter tools">' : '') +
622
+ '<button type="button" class="app-refresh" data-ability="' + escapeHtml(ability) + '"' + (apps.canConnect && !panel.busy ? '' : ' disabled') + '>Check for new tools</button></div>' +
623
+ (panel.message ? '<p class="' + (panel.messageBad ? 'error' : 'hint') + '" style="margin:0">' + escapeHtml(panel.message) + '</p>' : '') +
624
+ '<div class="app-tool-list">' + shown.map(function (t) {
625
+ var who = t.usedBy.length
626
+ ? 'Used by ' + t.usedBy.map(function (u) { return escapeHtml(u.agent) + ' (' + escapeHtml(DECISION_LABEL[u.decision]) + ')'; }).join(', ')
627
+ : 'Not used by an agent';
628
+ return '<div class="app-tool"><span><code>' + escapeHtml(t.name) + '</code> ' +
629
+ '<select class="app-decision" data-tool="' + escapeHtml(t.remote) + '" data-ability="' + escapeHtml(ability) + '" aria-label="Default permission for ' + escapeHtml(t.name) + '"' + (apps.canConnect && !panel.busy ? '' : ' disabled') + '>' +
630
+ ['allow', 'ask', 'deny'].map(function (d) { return '<option value="' + d + '"' + (d === t.decision ? ' selected' : '') + '>' + DECISION_LABEL[d] + '</option>'; }).join('') + '</select>' +
631
+ (t.destructive ? ' <span class="hint">destructive</span>' : t.readOnly ? ' <span class="hint">read-only</span>' : '') +
632
+ (t.description ? '<span class="hint app-tool-desc">' + escapeHtml(t.description) + '</span>' : '') +
633
+ '<span class="hint app-tool-desc">' + who + '</span></span></div>';
634
+ }).join('') + '</div></div>';
635
+ }
636
+
637
+ function loadTools(ability, fresh) {
638
+ openTools[ability] = { tools: null, filter: openTools[ability] ? openTools[ability].filter : '' };
639
+ renderApps();
640
+ fetch('/config/apps/tools?ability=' + encodeURIComponent(ability) + (fresh ? '&fresh=1' : ''), { cache: 'no-store' })
641
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
642
+ .then(function (r) {
643
+ if (!openTools[ability]) return;
644
+ if (!r.ok) openTools[ability].error = r.body.error || 'Could not list its tools.';
645
+ else openTools[ability].tools = r.body.tools;
646
+ renderApps();
647
+ })
648
+ .catch(function (err) { if (openTools[ability]) { openTools[ability].error = err.message; renderApps(); } });
649
+ }
650
+
651
+ // Sets a tool's default permission, or (tool null) checks the server
652
+ // for tools it added since.
653
+ function setDefault(ability, tool, decision) {
654
+ var panel = openTools[ability];
655
+ panel.busy = true;
656
+ panel.message = (tool ? 'Setting ' + tool + String.fromCharCode(8217) + 's default to ' + DECISION_LABEL[decision] : 'Checking the app for new tools') + String.fromCharCode(8230);
657
+ panel.messageBad = false;
658
+ renderApps();
659
+ var path = tool ? '/config/apps/tools' : '/config/apps/refresh';
660
+ fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ability: ability, tool: tool, decision: decision }) })
661
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
662
+ .then(function (r) {
663
+ panel.busy = false;
664
+ if (!r.ok) { panel.message = r.body.error || 'Could not change it.'; panel.messageBad = true; renderApps(); return; }
665
+ panel.tools = r.body.tools;
666
+ if (r.body.connected) apps = { apps: r.body.apps, connected: r.body.connected, canConnect: r.body.canConnect };
667
+ panel.message = tool
668
+ ? tool + ' now starts at ' + DECISION_LABEL[decision] + ' for agents that add it; agents that have it keep theirs.'
669
+ : (r.body.added.length || r.body.removed.length
670
+ ? (r.body.added.length ? 'New: ' + r.body.added.join(', ') + '. ' : '') + (r.body.removed.length ? 'No longer offered: ' + r.body.removed.join(', ') + '.' : '')
671
+ : 'No new tools.');
672
+ renderApps();
673
+ })
674
+ .catch(function (err) { panel.busy = false; panel.message = err.message; panel.messageBad = true; renderApps(); });
675
+ }
676
+
677
+ function findApp(c) {
678
+ for (var i = 0; i < apps.apps.length; i++) if (apps.apps[i].id === c.id || apps.apps[i].url === c.url) return apps.apps[i];
679
+ return null;
680
+ }
681
+
682
+ function renderAppSearch() {
683
+ if (appSearch.error) return '<p class="error">' + escapeHtml(appSearch.error) + '</p>';
684
+ if (!appSearch.servers) return '<p class="hint">Over a thousand apps publish an MCP server. Verified means it' + String.fromCharCode(8217) + 's on its publisher' + String.fromCharCode(8217) + 's own domain; only add a key to one you trust.</p>';
685
+ if (!appSearch.servers.length) return '<p class="hint">Nothing found for ' + escapeHtml(appSearch.q) + '.</p>';
686
+ return '<div class="app-list">' + appSearch.servers.map(function (s) {
687
+ var connected = apps.connected.some(function (c) { return c.url === s.url; });
688
+ return '<div class="app-row" data-app="' + escapeHtml(s.id) + '"><div class="app-head">' + appIcon(s) + '</div>' +
689
+ '<div class="app-row-main"><div><strong>' + escapeHtml(s.title) + '</strong> ' + (s.verified ? '<span class="badge badge-custom">Verified</span>' : '<span class="badge badge-default">Unverified</span>') + '</div>' +
690
+ '<div class="hint">' + escapeHtml(s.url) + (s.description ? ' &middot; ' + escapeHtml(s.description) : '') + '</div></div>' +
691
+ '<div class="app-slot">' + (connected ? '<span class="badge badge-custom">Connected</span>' : '<button type="button" class="app-connect" data-id="' + escapeHtml(s.id) + '"' + (apps.canConnect ? '' : ' disabled') + '>Connect</button>') + '</div></div>';
692
+ }).join('') + '</div>';
693
+ }
694
+
695
+ function loadApps() {
696
+ loadedSections.apps = true;
697
+ fetch('/config/apps', { cache: 'no-store' })
698
+ .then(function (r) { return r.json(); })
699
+ .then(function (d) { apps = d; renderApps(); })
700
+ .catch(function (err) { document.getElementById('appsContent').innerHTML = '<p class="error">Could not load apps: ' + escapeHtml(err.message) + '</p>'; });
701
+ }
702
+
703
+ document.getElementById('appsContent').addEventListener('click', function (ev) {
704
+ var t = ev.target;
705
+ if (t.dataset && t.dataset.appCat) { appCategory = t.dataset.appCat; renderApps(); return; }
706
+ if (t.classList.contains('app-refresh')) { setDefault(t.dataset.ability, null, null); return; }
707
+ if (t.classList.contains('app-tools-btn')) {
708
+ if (openTools[t.dataset.ability]) { delete openTools[t.dataset.ability]; renderApps(); }
709
+ else loadTools(t.dataset.ability, false);
710
+ return;
711
+ }
712
+ if (t.classList.contains('app-connect')) {
713
+ var slot = t.parentNode;
714
+ slot.innerHTML = connectForm(t.dataset.id);
715
+ slot.querySelector('input').focus();
716
+ return;
717
+ }
718
+ if (t.classList.contains('app-cancel')) { renderApps(); return; }
719
+ if (t.classList.contains('app-disconnect')) {
720
+ if (t.dataset.confirming !== '1') { t.dataset.confirming = '1'; t.textContent = 'Click again to disconnect'; return; }
721
+ t.disabled = true;
722
+ fetch('/config/apps/connect?ability=' + encodeURIComponent(t.dataset.ability), { method: 'DELETE' })
723
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
724
+ .then(function (r) {
725
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
726
+ apps = r.body;
727
+ renderApps();
728
+ })
729
+ .catch(function (err) { t.disabled = false; t.textContent = 'Could not disconnect: ' + err.message; });
730
+ }
731
+ });
732
+
733
+ document.getElementById('appsContent').addEventListener('change', function (ev) {
734
+ var t = ev.target;
735
+ if (t.classList.contains('app-decision')) setDefault(t.dataset.ability, t.dataset.tool, t.value);
736
+ });
737
+ document.getElementById('appsContent').addEventListener('input', function (ev) {
738
+ var t = ev.target;
739
+ if (!t.classList.contains('app-tools-filter')) return;
740
+ openTools[t.dataset.ability].filter = t.value;
741
+ renderApps();
742
+ var again = document.querySelector('.app-tools-filter[data-ability="' + t.dataset.ability + '"]');
743
+ if (again) { again.focus(); again.setSelectionRange(again.value.length, again.value.length); }
744
+ });
745
+
746
+ document.getElementById('appsContent').addEventListener('submit', function (ev) {
747
+ ev.preventDefault();
748
+ var form = ev.target;
749
+ if (form.id === 'appSearchForm') {
750
+ var q = form.q.value.trim();
751
+ appSearch = { q: q, servers: null, error: '' };
752
+ document.getElementById('appSearchResults').innerHTML = '<p class="hint">Searching' + String.fromCharCode(8230) + '</p>';
753
+ fetch('/config/apps/search?q=' + encodeURIComponent(q))
754
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
755
+ .then(function (r) {
756
+ if (!r.ok) throw new Error(r.body.error || 'search failed');
757
+ appSearch.servers = r.body.servers || [];
758
+ document.getElementById('appSearchResults').innerHTML = renderAppSearch();
759
+ })
760
+ .catch(function (err) { appSearch.error = 'Could not search: ' + err.message; document.getElementById('appSearchResults').innerHTML = renderAppSearch(); });
761
+ return;
762
+ }
763
+ if (!form.dataset.connect) return;
764
+ var btn = form.querySelector('button[type=submit]');
765
+ var msg = form.querySelector('.app-msg');
766
+ btn.disabled = true;
767
+ msg.className = 'hint app-msg';
768
+ msg.textContent = 'Connecting' + String.fromCharCode(8230) + ' it looks at the app' + String.fromCharCode(8217) + 's tools and sets their permissions, which can take a minute.';
769
+ fetch('/config/apps/connect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: form.dataset.connect, key: form.key.value }) })
770
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
771
+ .then(function (r) {
772
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
773
+ apps = r.body;
774
+ // Straight to its tools, to choose what agents may use.
775
+ if (r.body.connectedAbility) loadTools(r.body.connectedAbility, false);
776
+ else renderApps();
777
+ })
778
+ .catch(function (err) {
779
+ btn.disabled = false;
780
+ msg.className = 'error app-msg';
781
+ msg.textContent = err.message;
782
+ });
783
+ });
784
+
506
785
  // ---- Settings section ----
507
786
 
508
787
  function renderSettings(d) {
@@ -841,6 +1120,7 @@ ${consoleShellScript}
841
1120
  }
842
1121
  if (section === 'models' && !loadedSections.models) loadModels();
843
1122
  if (section === 'abilities' && !loadedSections.abilities) loadAbilities();
1123
+ if (section === 'apps' && !loadedSections.apps) loadApps();
844
1124
  if (section === 'settings' && !loadedSections.settings) loadSettings();
845
1125
  }
846
1126
 
@@ -855,7 +1135,7 @@ ${consoleShellScript}
855
1135
  var requestedSection = new URLSearchParams(location.search).get('section');
856
1136
  // ?section=gateways is the old Integrations page's link.
857
1137
  if (requestedSection === 'gateways') requestedSection = 'abilities';
858
- var initialSection = requestedSection === 'abilities' || requestedSection === 'models' || requestedSection === 'settings' ? requestedSection : currentSection;
1138
+ var initialSection = requestedSection === 'abilities' || requestedSection === 'apps' || requestedSection === 'models' || requestedSection === 'settings' ? requestedSection : currentSection;
859
1139
  switchSection(initialSection);
860
1140
  })();
861
1141
  </script>
@@ -125,6 +125,8 @@ export function setupChecklist(candidate, agentName) {
125
125
  switch (need.kind) {
126
126
  case 'ability':
127
127
  return { label: `Install the ${need.name ?? need.description} ability`, href: tab('abilities') };
128
+ case 'app':
129
+ return { label: `Connect ${need.name ?? need.description} on the Apps page, then add it from the agent's Apps tab`, href: '/config?section=apps' };
128
130
  case 'api':
129
131
  return { label: `Connect ${need.description} (an HTTP tool, or an MCP server)`, href: tab('advanced') };
130
132
  case 'key':
@@ -10,7 +10,12 @@ export interface TemplateCreateDeps {
10
10
  /** Installs an ability (an npm spec) into the agent. Absent where
11
11
  * installing isn't allowed (no admin login): the abilities then head the
12
12
  * checklist instead. */
13
- installAbility?: (name: string, spec: string) => Promise<void>;
13
+ installAbility?: (name: string, spec: string, options?: {
14
+ mcpTools?: string[];
15
+ }) => Promise<void>;
16
+ /** The workspace ability of a connected app (by catalog id), if it's
17
+ * connected. */
18
+ prepareApp?: (id: string) => Promise<string | undefined>;
14
19
  /** A setting as the agent sees it (its own, else the shared one). */
15
20
  envValue(name: string, variable: string): string | undefined;
16
21
  /** Whether approval requests from the agent reach anyone. */
@@ -37,12 +42,15 @@ export interface TemplateSummary {
37
42
  trigger: string;
38
43
  asksFirst: string[];
39
44
  abilities: string[];
45
+ apps: string[];
40
46
  schedule: string | null;
41
47
  }
42
48
  export interface TemplatePreview {
43
49
  name: string;
44
50
  systemPrompt: string;
45
51
  abilities: string[];
52
+ /** Apps it uses, by title. */
53
+ apps: string[];
46
54
  skills: string[];
47
55
  rules: {
48
56
  tool: string;
@@ -88,7 +96,7 @@ export declare function previewTemplate(id: string, deps: Pick<TemplateCreateDep
88
96
  export declare function templateChecklist(template: StarterTemplate, name: string, deps: Pick<TemplateCreateDeps, 'envValue' | 'messagingConfigured'>, failed?: {
89
97
  name: string;
90
98
  error: string;
91
- }[]): TemplateChecklistItem[];
99
+ }[], appsToConnect?: string[]): TemplateChecklistItem[];
92
100
  /** Creates a new agent from the template, with its whole setup, under the
93
101
  * first free name, so the same template can be used more than once. */
94
102
  export declare function createFromTemplate(id: string, deps: TemplateCreateDeps | LegacyTemplateCreateDeps): Promise<CreatedTemplateAgent>;
@@ -24,7 +24,7 @@ export function installFailureReason(err) {
24
24
  if (/E404|404 Not Found|is not in this registry/i.test(text))
25
25
  return "it isn't published on npm yet";
26
26
  if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN/.test(text))
27
- return 'npm could not be reached; try again from the Abilities tab';
27
+ return 'npm could not be reached; try again from the Apps tab';
28
28
  const line = text.split('\n').map((l) => l.trim()).find((l) => l && !/^Command failed:/.test(l)) ?? text.split('\n')[0];
29
29
  return line.length > 200 ? `${line.slice(0, 200)}…` : line;
30
30
  }
@@ -57,6 +57,7 @@ export function listTemplates() {
57
57
  trigger: setup.schedule ? describeTemplateSchedule(setup.schedule) : c.trigger.description,
58
58
  asksFirst: c.asksFirst,
59
59
  abilities: setup.abilities,
60
+ apps: (setup.apps ?? []).map((a) => a.title),
60
61
  schedule: setup.schedule ? describeTemplateSchedule(setup.schedule) : null,
61
62
  }));
62
63
  }
@@ -72,6 +73,7 @@ export function previewTemplate(id, deps) {
72
73
  name,
73
74
  systemPrompt: template.candidate.agent.systemPrompt,
74
75
  abilities: setup.abilities,
76
+ apps: (setup.apps ?? []).map((a) => a.title),
75
77
  skills: (setup.skills ?? []).map((s) => s.id),
76
78
  rules: setup.rules.map((r) => ({ tool: r.tool, decision: r.decision })),
77
79
  // A key set for the whole project already counts for a new agent.
@@ -83,9 +85,11 @@ export function previewTemplate(id, deps) {
83
85
  };
84
86
  }
85
87
  /** What's left once the agent exists, each linked to where it's done. */
86
- export function templateChecklist(template, name, deps, failed = []) {
88
+ export function templateChecklist(template, name, deps, failed = [], appsToConnect = []) {
87
89
  const tab = (t) => `/agents/config?agent=${encodeURIComponent(name)}&tab=${t}`;
88
90
  const items = [];
91
+ for (const title of appsToConnect)
92
+ items.push({ label: `Connect ${title} on the Apps page, then add it to ${name} from its Apps tab`, href: '/config?section=apps' });
89
93
  for (const f of failed)
90
94
  items.push({ label: `Install the ${f.name} ability (it couldn't be installed: ${f.error})`, href: tab('abilities') });
91
95
  const missing = keyGroups(template).filter((names) => !names.some((n) => deps.envValue(name, n)));
@@ -147,6 +151,35 @@ export async function createFromTemplateObject(template, deps) {
147
151
  failed.push({ name: spec, error: installFailureReason(err) });
148
152
  }
149
153
  }
154
+ // Apps: a connected one is added with just the tools the template relies
155
+ // on; one not connected heads the checklist.
156
+ const appsToConnect = [];
157
+ for (const app of setup.apps ?? []) {
158
+ let spec;
159
+ try {
160
+ spec = deps.prepareApp ? await deps.prepareApp(app.id) : undefined;
161
+ }
162
+ catch (err) {
163
+ failed.push({ name: app.title, error: err instanceof Error ? err.message : String(err) });
164
+ continue;
165
+ }
166
+ if (!spec) {
167
+ appsToConnect.push(app.title);
168
+ continue;
169
+ }
170
+ if (!deps.installAbility) {
171
+ failed.push({ name: app.title, error: 'adding apps needs LOOPENGINE_ADMIN_AUTH set on the server' });
172
+ continue;
173
+ }
174
+ try {
175
+ // Just the tools the template relies on.
176
+ await deps.installAbility(name, spec, { mcpTools: app.tools });
177
+ installed.push(app.title);
178
+ }
179
+ catch (err) {
180
+ failed.push({ name: app.title, error: installFailureReason(err) });
181
+ }
182
+ }
150
183
  // 3. Its own skills and non-secret settings.
151
184
  for (const skill of setup.skills ?? [])
152
185
  writeSkill(name, skill.id, { description: skill.description, body: skill.body });
@@ -182,7 +215,7 @@ export async function createFromTemplateObject(template, deps) {
182
215
  return {
183
216
  name,
184
217
  abilities: { installed, failed },
185
- checklist: templateChecklist(template, name, deps, failed),
218
+ checklist: templateChecklist(template, name, deps, failed, appsToConnect),
186
219
  ...(candidate.agent.exampleMessage ? { exampleMessage: candidate.agent.exampleMessage } : {}),
187
220
  ...(schedule ? { schedule } : {}),
188
221
  ...(scheduleError ? { scheduleError } : {}),
@@ -111,6 +111,7 @@ ${consoleShellScript}
111
111
  '<div><strong>Installs</strong> <span class="hint">(abilities: their tools, skills and permission rules)</span>' +
112
112
  listHtml(p.abilities.map(function (a) { return '<li><code>' + esc(a) + '</code></li>'; })) +
113
113
  (canInstall ? '' : '<p class="error" style="margin:4px 0 0">Installing abilities needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server. Without it, the agent is created and its abilities head the checklist.</p>') + '</div>' +
114
+ (p.apps && p.apps.length ? '<div><strong>Apps it uses</strong> <span class="hint">(connected on the <a href="/config?section=apps">Apps</a> page; one that isn' + String.fromCharCode(8217) + 't connected yet heads the checklist)</span>' + listHtml(p.apps.map(function (a) { return '<li>' + esc(a) + '</li>'; })) + '</div>' : '') +
114
115
  '<div><strong>Permissions</strong> <span class="hint">(anything not listed asks you first)</span>' +
115
116
  listHtml(p.rules.map(function (rule) { return '<li><code>' + esc(rule.tool) + '</code>: ' + esc(DECISIONS[rule.decision] || rule.decision) + '</li>'; })) + '</div>' +
116
117
  '<div><strong>Keys it needs</strong>' +