loopengine 0.3.9 → 0.3.10

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.
@@ -26,6 +26,7 @@ import { parse as parseYaml } from 'yaml';
26
26
  import semver from 'semver';
27
27
  import { agentDir } from '../core/gateway-tools.js';
28
28
  import { abilityFetchAuth } from '../core/ability-sources.js';
29
+ import { assertOfficialAbility } from '../core/official-abilities.js';
29
30
  import { clearMcpSchemaCache, resolveMcpManifest, McpManifestError } from '../core/mcp-abilities.js';
30
31
  import { appRuleName } from '../core/mcp-ability-generator.js';
31
32
  import { addToolToIndex, removeToolFromIndex, toCamelCase } from '../web/http-tool-admin.js';
@@ -363,6 +364,8 @@ function resolveMcp(manifest) {
363
364
  }
364
365
  }
365
366
  export async function installAbility(agentName, spec, options = {}) {
367
+ // A public ability only if LoopEngine publishes it (core/official-abilities.ts).
368
+ assertOfficialAbility(spec);
366
369
  const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
367
370
  const abilityDir = fetch(spec);
368
371
  const manifest = readManifest(abilityDir);
@@ -635,6 +638,8 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
635
638
  throw new AbilityNotInstalledError(`Ability '${abilityName}' isn't installed for '${agentName}'.`);
636
639
  }
637
640
  const spec = options.spec ?? abilityName;
641
+ // New code is fetched: the same check as an install.
642
+ assertOfficialAbility(spec);
638
643
  const oldDir = fetch(oldContentSpec(record.spec, abilityName, record.version));
639
644
  const newDir = fetch(spec);
640
645
  const oldManifest = readManifest(oldDir);
@@ -20,7 +20,9 @@ export interface SourceAbility {
20
20
  spec: string;
21
21
  }
22
22
  export interface AbilityOrigin {
23
- kind: 'public' | 'private';
23
+ /** official: under LoopEngine's @loopengine-co scope; public: any
24
+ * other npm package (an older lp-* one, say). */
25
+ kind: 'official' | 'public' | 'private';
24
26
  /** Where it came from, for the console's badge: "npm", "GitHub
25
27
  * loopengine-co/lp-github", "Folder ../loopengine-abilities", ... */
26
28
  label: string;
@@ -260,7 +260,7 @@ export function abilityOrigin(spec, name, sources = safeReadSources()) {
260
260
  const npmSource = scope ? sources.find((s) => s.type === 'npm' && s.scope === scope) : undefined;
261
261
  if (npmSource)
262
262
  return { kind: 'private', label: describeSource(npmSource) };
263
- return { kind: 'public', label: 'npm' };
263
+ return { kind: target.toLowerCase().startsWith('@loopengine-co/') ? 'official' : 'public', label: 'npm' };
264
264
  }
265
265
  function safeReadSources() {
266
266
  try {
@@ -26,6 +26,22 @@ export declare const MCP_REGISTRY_URL_ENV = "LOOPENGINE_MCP_REGISTRY_URL";
26
26
  export declare const MCP_SERVERS_FILE_ENV = "LOOPENGINE_MCP_SERVERS_FILE";
27
27
  /** The workspace's saved servers. A malformed entry is skipped. */
28
28
  export declare function savedMcpServers(): McpDirectoryServer[];
29
+ export declare class McpServerInputError extends Error {
30
+ }
31
+ /** A server the workspace adds by its address, checked and given an id
32
+ * from its name (the same id again for an address already saved). Not
33
+ * saved yet: saveMcpServer, once it has connected. */
34
+ export declare function ownMcpServer(input: {
35
+ title?: unknown;
36
+ url?: unknown;
37
+ description?: unknown;
38
+ header?: unknown;
39
+ }, saved?: McpDirectoryServer[]): McpDirectoryServer;
40
+ /** Adds a server to the saved servers, or replaces the one with its id.
41
+ * The file's other entries are kept as they are. */
42
+ export declare function saveMcpServer(server: McpDirectoryServer): void;
43
+ /** Removes a saved server; false when there's none with that id. */
44
+ export declare function removeSavedMcpServer(id: string): boolean;
29
45
  /** Saved servers matching `query`, then registry ones, verified first. */
30
46
  export declare function searchMcpServers(query: string, limit?: number): Promise<McpDirectoryServer[]>;
31
47
  /** One server by id: a saved one, else the registry's latest entry. */
@@ -11,12 +11,15 @@
11
11
  // url: https://mcp.acme.internal/mcp
12
12
  // auth: { header: X-Api-Key } # optional; default Authorization: Bearer
13
13
  //
14
+ // The console's Apps page adds to the same file ("Add your own server"):
15
+ // a company's own server, connected like any app.
16
+ //
14
17
  // A registry server counts as verified when its URL is on the domain its
15
18
  // registry name was verified for (com.stripe/mcp at mcp.stripe.com); the
16
19
  // rest are shown as unverified.
17
- import { existsSync, readFileSync } from 'node:fs';
20
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
18
21
  import { resolve } from 'node:path';
19
- import { parse as parseYaml } from 'yaml';
22
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
20
23
  export const MCP_REGISTRY_URL_ENV = 'LOOPENGINE_MCP_REGISTRY_URL';
21
24
  export const MCP_SERVERS_FILE_ENV = 'LOOPENGINE_MCP_SERVERS_FILE';
22
25
  const DEFAULT_REGISTRY = 'https://registry.modelcontextprotocol.io';
@@ -27,9 +30,12 @@ function registryBase() {
27
30
  return undefined;
28
31
  return (value || DEFAULT_REGISTRY).replace(/\/+$/, '');
29
32
  }
33
+ function savedServersPath() {
34
+ return resolve(process.cwd(), process.env[MCP_SERVERS_FILE_ENV]?.trim() || 'mcp-servers.yml');
35
+ }
30
36
  /** The workspace's saved servers. A malformed entry is skipped. */
31
37
  export function savedMcpServers() {
32
- const path = resolve(process.cwd(), process.env[MCP_SERVERS_FILE_ENV]?.trim() || 'mcp-servers.yml');
38
+ const path = savedServersPath();
33
39
  if (!existsSync(path))
34
40
  return [];
35
41
  let raw;
@@ -71,6 +77,93 @@ function isRemoteUrl(url) {
71
77
  return false;
72
78
  }
73
79
  }
80
+ export class McpServerInputError extends Error {
81
+ }
82
+ const HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
83
+ /** A server the workspace adds by its address, checked and given an id
84
+ * from its name (the same id again for an address already saved). Not
85
+ * saved yet: saveMcpServer, once it has connected. */
86
+ export function ownMcpServer(input, saved = savedMcpServers()) {
87
+ const text = (v) => (typeof v === 'string' ? v.trim() : '');
88
+ const title = text(input.title);
89
+ const url = text(input.url);
90
+ const description = text(input.description);
91
+ const header = text(input.header);
92
+ if (!title)
93
+ throw new McpServerInputError('Give the server a name, e.g. "Acme CRM".');
94
+ if (title.length > 60)
95
+ throw new McpServerInputError('Keep the name under 60 characters.');
96
+ if (description.length > 200)
97
+ throw new McpServerInputError('Keep the description under 200 characters.');
98
+ let parsed;
99
+ try {
100
+ parsed = new URL(url);
101
+ }
102
+ catch {
103
+ throw new McpServerInputError('Enter the server\'s full address, e.g. https://mcp.yourcompany.com/mcp.');
104
+ }
105
+ if (!isRemoteUrl(parsed.href)) {
106
+ throw new McpServerInputError(parsed.protocol === 'http:'
107
+ ? 'Use the server\'s https address: its key would otherwise be sent unencrypted.'
108
+ : 'Enter the server\'s full https address, with nothing left to fill in, e.g. https://mcp.yourcompany.com/mcp.');
109
+ }
110
+ if (header && !HEADER_NAME.test(header))
111
+ throw new McpServerInputError('A header name is letters, digits and dashes, e.g. X-Api-Key.');
112
+ const same = saved.find((s) => s.url === url);
113
+ let id = same?.id;
114
+ if (!id) {
115
+ const base = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'server';
116
+ id = base;
117
+ for (let n = 2; saved.some((s) => s.id === id); n++)
118
+ id = `${base}-${n}`;
119
+ }
120
+ return {
121
+ id,
122
+ title,
123
+ description,
124
+ url,
125
+ transport: 'streamable-http',
126
+ source: 'saved',
127
+ verified: true,
128
+ ...(header && header.toLowerCase() !== 'authorization' ? { auth: { header } } : {}),
129
+ };
130
+ }
131
+ /** Adds a server to the saved servers, or replaces the one with its id.
132
+ * The file's other entries are kept as they are. */
133
+ export function saveMcpServer(server) {
134
+ const path = savedServersPath();
135
+ let doc = {};
136
+ if (existsSync(path)) {
137
+ try {
138
+ doc = parseYaml(readFileSync(path, 'utf8')) ?? {};
139
+ }
140
+ catch (err) {
141
+ throw new McpServerInputError(`${path} couldn't be read, so the server wasn't saved: ${err instanceof Error ? err.message : String(err)}`);
142
+ }
143
+ }
144
+ const servers = Array.isArray(doc.servers) ? doc.servers.filter((e) => e?.id !== server.id) : [];
145
+ servers.push({
146
+ id: server.id,
147
+ title: server.title,
148
+ ...(server.description ? { description: server.description } : {}),
149
+ url: server.url,
150
+ ...(server.auth?.header ? { auth: { header: server.auth.header } } : {}),
151
+ });
152
+ writeFileSync(path, stringifyYaml({ ...doc, servers }));
153
+ }
154
+ /** Removes a saved server; false when there's none with that id. */
155
+ export function removeSavedMcpServer(id) {
156
+ const path = savedServersPath();
157
+ if (!existsSync(path))
158
+ return false;
159
+ const doc = parseYaml(readFileSync(path, 'utf8')) ?? {};
160
+ const servers = Array.isArray(doc.servers) ? doc.servers : [];
161
+ const kept = servers.filter((e) => e?.id !== id);
162
+ if (kept.length === servers.length)
163
+ return false;
164
+ writeFileSync(path, stringifyYaml({ ...doc, servers: kept }));
165
+ return true;
166
+ }
74
167
  /** io.github.firecrawl/… → firecrawl: the GitHub organisation the
75
168
  * registry verified the name for. */
76
169
  function githubOrg(name) {
@@ -0,0 +1,14 @@
1
+ /** LoopEngine's npm organisation: its scope is official by itself. */
2
+ export declare const OFFICIAL_ABILITY_SCOPE = "@loopengine-co";
3
+ /** Whether a package name is under LoopEngine's npm organisation. */
4
+ export declare function isOfficialScope(name: string): boolean;
5
+ export declare class UnofficialAbilityError extends Error {
6
+ }
7
+ export declare function unofficialAbilitiesAllowed(): boolean;
8
+ /** The package name when a spec installs from the public npm registry:
9
+ * a bare name or @scope/name, with an optional @version, whose scope isn't
10
+ * one of the workspace's private registries. Undefined for anything else. */
11
+ export declare function publicNpmPackage(spec: string): string | undefined;
12
+ /** Refuses a public npm ability that isn't official, before anything is
13
+ * fetched. */
14
+ export declare function assertOfficialAbility(spec: string): void;
@@ -0,0 +1,58 @@
1
+ // Official abilities: of the public ones on npm, only those LoopEngine
2
+ // publishes are listed and installed. Anyone can publish a package with the
3
+ // loopengine-ability keyword, or an lp-* name, and an ability runs code in
4
+ // the agent's server with the agent's keys; so "official" is decided by who
5
+ // can publish it: anything under the @loopengine-co scope, which only
6
+ // members of LoopEngine's npm organisation can publish to. The older
7
+ // unscoped ones (lp-gmail and the rest) aren't, and can't be installed or
8
+ // upgraded; their official versions are under the scope.
9
+ //
10
+ // Private abilities are the workspace's own and stay allowed: a workspace
11
+ // ability (file:), one from a GitHub source (github:owner/repo, git URLs),
12
+ // and one from a private npm registry the workspace set up for a scope
13
+ // (core/ability-sources.ts).
14
+ //
15
+ // LOOPENGINE_ALLOW_UNOFFICIAL_ABILITIES=1 turns the check off, for someone
16
+ // developing their own public ability on a self-hosted server.
17
+ import { readAbilitySources } from './ability-sources.js';
18
+ /** LoopEngine's npm organisation: its scope is official by itself. */
19
+ export const OFFICIAL_ABILITY_SCOPE = '@loopengine-co';
20
+ /** Whether a package name is under LoopEngine's npm organisation. */
21
+ export function isOfficialScope(name) {
22
+ return name.toLowerCase().startsWith(`${OFFICIAL_ABILITY_SCOPE}/`);
23
+ }
24
+ export class UnofficialAbilityError extends Error {
25
+ }
26
+ export function unofficialAbilitiesAllowed() {
27
+ return process.env.LOOPENGINE_ALLOW_UNOFFICIAL_ABILITIES === '1';
28
+ }
29
+ /** The package name when a spec installs from the public npm registry:
30
+ * a bare name or @scope/name, with an optional @version, whose scope isn't
31
+ * one of the workspace's private registries. Undefined for anything else. */
32
+ export function publicNpmPackage(spec) {
33
+ const match = /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)(?:@[^/]+)?$/i.exec(spec.trim());
34
+ if (!match)
35
+ return undefined;
36
+ const name = match[1];
37
+ if (name.startsWith('@')) {
38
+ const scope = name.split('/')[0].toLowerCase();
39
+ let privateScopes = [];
40
+ try {
41
+ privateScopes = readAbilitySources().flatMap((s) => (s.type === 'npm' ? [s.scope.toLowerCase()] : []));
42
+ }
43
+ catch { }
44
+ if (privateScopes.includes(scope))
45
+ return undefined;
46
+ }
47
+ return name;
48
+ }
49
+ /** Refuses a public npm ability that isn't official, before anything is
50
+ * fetched. */
51
+ export function assertOfficialAbility(spec) {
52
+ if (unofficialAbilitiesAllowed())
53
+ return;
54
+ const name = publicNpmPackage(spec);
55
+ if (!name || isOfficialScope(name))
56
+ return;
57
+ throw new UnofficialAbilityError(`${name} isn't an official LoopEngine ability, so it can't be installed: official abilities are under ${OFFICIAL_ABILITY_SCOPE} on npm (search them on the Abilities page). To use your own, add it as a private ability (Settings → Private ability sources, or upload it on the Abilities page).`);
58
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AgentCandidate } from './interviewer.js';
2
+ import type { WorkflowParameter } from '../workflows.js';
2
3
  export interface TemplateRule {
3
4
  name: string;
4
5
  tool: string;
@@ -9,6 +10,14 @@ export interface TemplateSkill {
9
10
  id: string;
10
11
  description: string;
11
12
  body: string;
13
+ /** Makes it a workflow, with `body` as its instructions. `webhook`: its
14
+ * checklist says to add a webhook that sends each result (the URL and
15
+ * the sender's signing secret are set up with the sender). */
16
+ workflow?: {
17
+ title: string;
18
+ parameters?: Record<string, WorkflowParameter>;
19
+ webhook?: boolean;
20
+ };
12
21
  }
13
22
  export interface TemplateSchedule {
14
23
  /** How often, in the scheduler's terms: "10m", "1h", "7d". */
@@ -2,9 +2,8 @@ import { nextTimeOfDay, parseTimeOfDay, workspaceTimeZone } from '../time-zone.j
2
2
  const GMAIL_KEYS = ['GMAIL_CLIENT_ID', 'GMAIL_CLIENT_SECRET', 'GMAIL_REFRESH_TOKEN'];
3
3
  const SHOPIFY_KEYS = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_ADMIN_TOKEN'];
4
4
  const GITHUB_APP = 'io.github.github/github-mcp-server';
5
- const SLACK_KEYS = ['SLACK_BOT_TOKEN', 'SLACK_DEFAULT_CHANNEL'];
6
- const WEB_SEARCH_KEYS = [['TAVILY_API_KEY', 'BRAVE_API_KEY', 'FIRECRAWL_API_KEY']];
7
- const DOCS_NOTE = 'Put your policies and help articles in the project\'s docs folder, as Markdown or text files (or point DOCS_SEARCH_PATH at another folder)';
5
+ const FIRECRAWL_APP = 'io.github.firecrawl/firecrawl-mcp-server';
6
+ const policiesNote = (what, skill) => `Write ${what} into its ${skill} skill (Advanced → Skills): the agent answers from it and nothing else`;
8
7
  export const STARTER_TEMPLATES = [
9
8
  {
10
9
  id: 'customer-email-replies',
@@ -18,9 +17,9 @@ export const STARTER_TEMPLATES = [
18
17
  risk: 'medium',
19
18
  asksFirst: ['sending a reply'],
20
19
  needs: [
21
- { kind: 'ability', name: 'lp-gmail', description: 'your support inbox' },
22
- { kind: 'ability', name: 'lp-shopify', description: 'your store\'s orders' },
23
- { kind: 'ability', name: 'lp-docs-search', description: 'your help articles and policies' },
20
+ { kind: 'ability', name: '@loopengine-co/gmail', description: 'your support inbox' },
21
+ { kind: 'ability', name: '@loopengine-co/shopify', description: 'your store\'s orders' },
22
+ { kind: 'other', name: 'Your policies', description: 'shipping, returns and the rest, in its support-policies skill' },
24
23
  ],
25
24
  agent: {
26
25
  name: 'customer-email-replies',
@@ -30,7 +29,7 @@ When asked to handle new emails:
30
29
  1. Find them with gmail_search, query "is:unread in:inbox". Skip newsletters, receipts, automatic notifications and "no-reply" senders: mark those read and move on.
31
30
  2. For each customer email, read the whole conversation with gmail_get_thread first.
32
31
  3. Work out what they want: order status, a product question, a return or refund, a complaint, or something else.
33
- 4. Look up what you need before answering: the order with shopify_find_orders (by the order number they give, or their email) and shopify_get_order, and our policies with search_docs. Never guess an order detail, a date or a policy.
32
+ 4. Look up what you need before answering: the order with shopify_find_orders (by the order number they give, or their email) and shopify_get_order, and our policies in the support-policies skill (open it with Skill). Never guess an order detail, a date or a policy.
34
33
  5. Reply with gmail_send, passing reply_to_message_id so it stays in the same thread: short, friendly, in the customer's language, using their name, answering the question first and saying what happens next.
35
34
  6. Mark the email read with gmail_modify_labels (remove "UNREAD").
36
35
 
@@ -44,9 +43,31 @@ Rules:
44
43
  },
45
44
  },
46
45
  setup: {
47
- abilities: ['lp-gmail', 'lp-shopify', 'lp-docs-search'],
46
+ abilities: ['@loopengine-co/gmail', '@loopengine-co/shopify'],
48
47
  keys: [...GMAIL_KEYS, ...SHOPIFY_KEYS],
49
- env: { DOCS_SEARCH_PATH: 'docs' },
48
+ skills: [{
49
+ id: 'support-policies',
50
+ description: 'Our shipping, returns, refunds and other customer policies. Read it before telling a customer anything about a policy.',
51
+ body: `# Our customer policies
52
+
53
+ Edit this: replace each [bracket] with your own policy. The agent answers customers from this and nothing else.
54
+
55
+ ## Shipping
56
+ - Where we ship: [countries]
57
+ - How long it takes: [e.g. 3–5 business days]
58
+ - Tracking: [when and how customers get it]
59
+
60
+ ## Returns and refunds
61
+ - Return window: [e.g. 30 days from delivery]
62
+ - What can't be returned: [e.g. sale items, opened cosmetics]
63
+ - How refunds are paid: [e.g. to the original payment method, in 5–10 business days]
64
+
65
+ ## Other questions
66
+ - [Order changes and cancellations]
67
+ - [Warranty]
68
+ - [Anything customers often ask]
69
+ `,
70
+ }],
50
71
  rules: [
51
72
  { name: 'template-read-mail-allowed', tool: 'gmail_search', decision: 'allow' },
52
73
  { name: 'template-read-thread-allowed', tool: 'gmail_get_thread', decision: 'allow' },
@@ -57,7 +78,7 @@ Rules:
57
78
  ],
58
79
  schedule: { every: '10m', name: 'Check the support inbox', message: 'Handle new customer emails in the support inbox.' },
59
80
  needsApprovals: true,
60
- notes: [DOCS_NOTE],
81
+ notes: [policiesNote('your policies', 'support-policies')],
61
82
  },
62
83
  },
63
84
  {
@@ -72,9 +93,9 @@ Rules:
72
93
  risk: 'high',
73
94
  asksFirst: ['issuing a refund', 'sending the reply'],
74
95
  needs: [
75
- { kind: 'ability', name: 'lp-gmail', description: 'your support inbox' },
76
- { kind: 'ability', name: 'lp-shopify', description: 'your store\'s orders and refunds' },
77
- { kind: 'ability', name: 'lp-docs-search', description: 'your return policy' },
96
+ { kind: 'ability', name: '@loopengine-co/gmail', description: 'your support inbox' },
97
+ { kind: 'ability', name: '@loopengine-co/shopify', description: 'your store\'s orders and refunds' },
98
+ { kind: 'other', name: 'Your return policy', description: 'in its return-policy skill' },
78
99
  ],
79
100
  agent: {
80
101
  name: 'refund-approvals',
@@ -83,7 +104,7 @@ Rules:
83
104
  When asked to handle new refund requests:
84
105
  1. Find them with gmail_search, query "is:unread in:inbox (refund OR return OR damaged OR broken OR "money back")". Read each conversation with gmail_get_thread. If an email isn't really a refund or return request, leave it unread for the support team.
85
106
  2. Find the order with shopify_find_orders (the order number they give, or their email) and read it with shopify_get_order: what was bought, when, how much, what's already been refunded, whether it was delivered.
86
- 3. Check the request against our return policy with search_docs: the return window, whether the item is eligible, and whether the reason is covered.
107
+ 3. Check the request against our return policy in the return-policy skill (open it with Skill): the return window, whether the item is eligible, and whether the reason is covered.
87
108
  4. Decide what to propose: a full refund, a partial refund, refunding shipping, or no refund (and which part of the policy says so).
88
109
  5. Work out the exact amount with shopify_preview_refund. Never state an amount you haven't previewed.
89
110
  6. Issue it with shopify_create_refund, with a note giving the policy rule and the facts from the order. A person approves it first.
@@ -99,9 +120,24 @@ Rules:
99
120
  },
100
121
  },
101
122
  setup: {
102
- abilities: ['lp-gmail', 'lp-shopify', 'lp-docs-search'],
123
+ abilities: ['@loopengine-co/gmail', '@loopengine-co/shopify'],
103
124
  keys: [...GMAIL_KEYS, ...SHOPIFY_KEYS],
104
- env: { DOCS_SEARCH_PATH: 'docs' },
125
+ skills: [{
126
+ id: 'return-policy',
127
+ description: 'Our return and refund policy: the window, what qualifies, and how much is refunded. Read it before proposing any refund.',
128
+ body: `# Our return policy
129
+
130
+ Edit this: replace each [bracket] with your own policy. The agent proposes refunds from this and nothing else.
131
+
132
+ - Return window: [e.g. 30 days from delivery]
133
+ - Eligible items: [e.g. unused, in the original packaging]
134
+ - Not eligible: [e.g. sale items, gift cards, personalised items]
135
+ - Damaged or wrong items: [e.g. full refund including shipping, no return needed]
136
+ - Change of mind: [e.g. refund of the item price, customer pays return shipping]
137
+ - Shipping costs: [when they're refunded]
138
+ - Partial refunds: [when you offer one, and how much]
139
+ `,
140
+ }],
105
141
  rules: [
106
142
  { name: 'template-read-mail-allowed', tool: 'gmail_search', decision: 'allow' },
107
143
  { name: 'template-read-thread-allowed', tool: 'gmail_get_thread', decision: 'allow' },
@@ -113,7 +149,7 @@ Rules:
113
149
  ],
114
150
  schedule: { every: '10m', name: 'Check for refund requests', message: 'Handle new refund and return requests in the support inbox.' },
115
151
  needsApprovals: true,
116
- notes: [DOCS_NOTE + ' — especially your return policy'],
152
+ notes: [policiesNote('your return policy', 'return-policy')],
117
153
  },
118
154
  },
119
155
  {
@@ -121,15 +157,15 @@ Rules:
121
157
  category: 'Operations',
122
158
  candidate: {
123
159
  title: 'Triage incidents',
124
- summary: 'Takes an alert or a bug report, works out what broke and how bad it is, finds the deploys and changes that likely caused it, and posts a summary to Slack. Opening or commenting on issues waits for approval.',
125
- trigger: { kind: 'on_request', description: 'when an alert or report is passed to it in Chat, Slack or over HTTP' },
160
+ summary: 'Takes an alert or a bug report, works out what broke and how bad it is, finds the deploys and changes that likely caused it, and sends a summary to Slack. Opening or commenting on issues waits for approval.',
161
+ trigger: { kind: 'event', description: 'when your alerting tool calls its webhook, or someone passes it an alert in Chat or Slack' },
126
162
  hoursPerWeek: 3,
127
163
  fit: 6,
128
164
  risk: 'medium',
129
165
  asksFirst: ['opening or commenting on issues'],
130
166
  needs: [
131
167
  { kind: 'app', name: 'GitHub', description: 'your repository\'s commits, deploys and issues' },
132
- { kind: 'ability', name: 'lp-slack-notifier', description: 'posting the summary to Slack' },
168
+ { kind: 'messaging', name: 'Slack', description: 'where each summary is sent (its Messaging tab)' },
133
169
  ],
134
170
  agent: {
135
171
  name: 'incident-triage',
@@ -137,32 +173,38 @@ Rules:
137
173
 
138
174
  Our repository on GitHub: [owner/name] (edit this). Pass its owner and repo to every GitHub tool.
139
175
 
140
- When you get an alert, error or bug report:
141
- 1. Summarise it in two sentences: what is failing and who is affected.
142
- 2. Rate the severity: SEV1 (customers can't use the product), SEV2 (a major feature is broken), SEV3 (degraded or a workaround exists), SEV4 (minor).
143
- 3. Find what changed shortly before it started: github_actions_list (method list_workflow_runs) for recent deploys and failed builds, github_list_pull_requests (state closed) for recently merged changes, and github_list_commits (narrowed with since and path) for detail. Match times carefully.
144
- 4. Check whether it's already known with github_search_issues; read a match with github_issue_read (method get).
145
- 5. Post a short summary to Slack with post_to_slack: what's broken, severity, the most likely cause with links, and one next step (who should look, what to check, or whether to roll back).
146
- 6. If an issue already tracks it, add your findings with github_add_issue_comment; otherwise, for SEV1 or SEV2, open one with github_issue_write (method create). Both ask a person first.
176
+ When you get an alert, error or bug report, follow your triage-incident workflow.
147
177
 
148
178
  Rules:
149
179
  - Only read on your own; never roll anything back yourself.
150
180
  - Say how sure you are. If the evidence is thin, say what would confirm it instead of guessing.
151
- - Keep the Slack summary short enough to read on a phone.`,
181
+ - Keep the summary short enough to read on a phone.`,
152
182
  exampleMessage: 'Alert: checkout-api error rate 18% (normally under 1%) since 14:05 UTC. Errors: "timeout connecting to payments".',
153
183
  },
154
184
  },
155
185
  setup: {
156
- abilities: ['lp-slack-notifier'],
186
+ abilities: [],
157
187
  apps: [{ id: GITHUB_APP, title: 'GitHub', tools: ['list_commits', 'list_pull_requests', 'actions_list', 'search_issues', 'issue_read', 'issue_write', 'add_issue_comment'] }],
158
- keys: [...SLACK_KEYS],
188
+ keys: [],
189
+ skills: [{
190
+ id: 'triage-incident',
191
+ description: 'Triage an alert, error or bug report: what broke, how bad it is, and the change that likely caused it.',
192
+ workflow: { title: 'Triage an incident', parameters: { alert: { description: 'The alert, error or bug report', required: true } }, webhook: true },
193
+ body: `Triage the alert you were given.
194
+
195
+ 1. Summarise it in two sentences: what is failing and who is affected.
196
+ 2. Rate the severity: SEV1 (customers can't use the product), SEV2 (a major feature is broken), SEV3 (degraded or a workaround exists), SEV4 (minor).
197
+ 3. Find what changed shortly before it started: github_actions_list (method list_workflow_runs) for recent deploys and failed builds, github_list_pull_requests (state closed) for recently merged changes, and github_list_commits (narrowed with since and path) for detail. Match times carefully.
198
+ 4. Check whether it's already known with github_search_issues; read a match with github_issue_read (method get).
199
+ 5. If an issue already tracks it, add your findings with github_add_issue_comment; otherwise, for SEV1 or SEV2, open one with github_issue_write (method create). Both ask a person first.
200
+ 6. End with the summary, which is sent to Slack: what's broken, severity, the most likely cause with links, and one next step (who should look, what to check, or whether to roll back).`,
201
+ }],
159
202
  rules: [
160
- { name: 'template-slack-summary-allowed', tool: 'post_to_slack', decision: 'allow' },
161
203
  { name: 'template-new-issues-need-approval', tool: 'github_issue_write', decision: 'ask' },
162
204
  { name: 'template-comments-need-approval', tool: 'github_add_issue_comment', decision: 'ask' },
163
205
  ],
164
206
  needsApprovals: true,
165
- notes: ['Send it alerts: paste one into Chat, have your alerting tool call it over HTTP, or reply to it in Slack'],
207
+ notes: ['Send it alerts: paste one into Chat, mention it in Slack, or have your alerting tool call its webhook'],
166
208
  },
167
209
  },
168
210
  {
@@ -177,8 +219,8 @@ Rules:
177
219
  risk: 'high',
178
220
  asksFirst: ['replying to the claimant'],
179
221
  needs: [
180
- { kind: 'ability', name: 'lp-gmail', description: 'the inbox where claims arrive' },
181
- { kind: 'ability', name: 'lp-docs-search', description: 'your expense policy' },
222
+ { kind: 'ability', name: '@loopengine-co/gmail', description: 'the inbox where claims arrive' },
223
+ { kind: 'other', name: 'Your expense policy', description: 'in its expense-policy skill' },
182
224
  ],
183
225
  agent: {
184
226
  name: 'expense-approvals',
@@ -187,7 +229,7 @@ Rules:
187
229
  When asked to check new claims:
188
230
  1. Find them with gmail_search, query "is:unread in:inbox". Read each with gmail_get_thread.
189
231
  2. Read the amount, currency, date, merchant, category and who submitted it, from the email and its receipt (the attachment names show whether a receipt is there).
190
- 3. Check it against the expense policy with search_docs: the category is allowed, it's under the limit, the receipt matches the amount and date, and it isn't a duplicate of an earlier claim (search the inbox for the same merchant and amount).
232
+ 3. Check it against our expense policy in the expense-policy skill (open it with Skill): the category is allowed, it's under the limit, the receipt matches the amount and date, and it isn't a duplicate of an earlier claim (search the inbox for the same merchant and amount).
191
233
  4. Write a one-line verdict: "Within policy" or "Outside policy: <reason>", then the details you checked.
192
234
  5. Email the verdict to the approver at the address in the EXPENSE_APPROVER_EMAIL setting with gmail_send, then mark the claim read with gmail_modify_labels.
193
235
 
@@ -200,9 +242,30 @@ Rules:
200
242
  },
201
243
  },
202
244
  setup: {
203
- abilities: ['lp-gmail', 'lp-docs-search'],
245
+ abilities: ['@loopengine-co/gmail'],
204
246
  keys: [...GMAIL_KEYS],
205
- env: { DOCS_SEARCH_PATH: 'docs' },
247
+ skills: [{
248
+ id: 'expense-policy',
249
+ description: 'Our expense policy: what can be claimed, the limits, and what a claim needs. Read it before giving any verdict.',
250
+ body: `# Our expense policy
251
+
252
+ Edit this: replace each [bracket] with your own policy. The agent checks claims against this and nothing else.
253
+
254
+ ## What can be claimed, and the limits
255
+ - Travel: [e.g. economy flights and trains; taxis when there's no public transport]
256
+ - Hotels: [e.g. up to 150 per night]
257
+ - Meals: [e.g. up to 50 per day when travelling]
258
+ - Equipment and software: [e.g. up to 200 without pre-approval]
259
+
260
+ ## What can't be claimed
261
+ - [e.g. alcohol, fines, personal entertainment]
262
+
263
+ ## What every claim needs
264
+ - An itemised receipt showing the amount, date and merchant
265
+ - [Pre-approval for anything over a limit]
266
+ - Submitted within [e.g. 60 days]
267
+ `,
268
+ }],
206
269
  rules: [
207
270
  { name: 'template-read-mail-allowed', tool: 'gmail_search', decision: 'allow' },
208
271
  { name: 'template-read-thread-allowed', tool: 'gmail_get_thread', decision: 'allow' },
@@ -212,7 +275,7 @@ Rules:
212
275
  ],
213
276
  schedule: { every: '15m', name: 'Check for new claims', message: 'Check the new expense claims in the inbox.' },
214
277
  needsApprovals: true,
215
- notes: [DOCS_NOTE + ' — especially your expense policy', 'Set EXPENSE_APPROVER_EMAIL (Environment tab) to the approver\'s address'],
278
+ notes: [policiesNote('your expense policy', 'expense-policy'), 'Set EXPENSE_APPROVER_EMAIL (Environment tab) to the approver\'s address'],
216
279
  },
217
280
  },
218
281
  {
@@ -227,9 +290,9 @@ Rules:
227
290
  risk: 'medium',
228
291
  asksFirst: ['sending the first email', 'updating the lead'],
229
292
  needs: [
230
- { kind: 'ability', name: 'lp-hubspot', description: 'your leads in HubSpot' },
231
- { kind: 'ability', name: 'lp-web-search', description: 'researching each lead' },
232
- { kind: 'ability', name: 'lp-gmail', description: 'the inbox to send from' },
293
+ { kind: 'ability', name: '@loopengine-co/hubspot', description: 'your leads in HubSpot' },
294
+ { kind: 'app', name: 'Firecrawl', description: 'researching each lead on the web' },
295
+ { kind: 'ability', name: '@loopengine-co/gmail', description: 'the inbox to send from' },
233
296
  ],
234
297
  agent: {
235
298
  name: 'lead-follow-up',
@@ -243,7 +306,7 @@ What we sell (edit this):
243
306
  When asked to follow up with new leads:
244
307
  1. Find them with hubspot_find_contacts: lead_status "NEW", created_in_last_days 2.
245
308
  2. For each, read hubspot_get_contact. Skip anyone with an owner, recent notes, or a last-contacted date: someone is already on it.
246
- 3. Research them with web_search and web_fetch: what their company does, its size, and anything recent that matters (funding, hiring, a launch). Note your sources.
309
+ 3. Research them with firecrawl_search and firecrawl_scrape: what their company does, its size, and anything recent that matters (funding, hiring, a launch). Note your sources.
247
310
  4. Work out what they most likely need from us, in one sentence.
248
311
  5. Send a short first email (under 120 words) with gmail_send: a specific opening based on your research, one sentence on how we help with that need, and one easy question to reply to. A person approves it first.
249
312
  6. Log what you sent and found with hubspot_add_note, and set the lead to "ATTEMPTED_TO_CONTACT" with hubspot_update_contact.
@@ -257,9 +320,12 @@ Rules:
257
320
  },
258
321
  },
259
322
  setup: {
260
- abilities: ['lp-hubspot', 'lp-web-search', 'lp-gmail'],
261
- keys: ['HUBSPOT_ACCESS_TOKEN', ...WEB_SEARCH_KEYS, ...GMAIL_KEYS],
323
+ abilities: ['@loopengine-co/hubspot', '@loopengine-co/gmail'],
324
+ apps: [{ id: FIRECRAWL_APP, title: 'Firecrawl', tools: ['firecrawl_search', 'firecrawl_scrape'] }],
325
+ keys: ['HUBSPOT_ACCESS_TOKEN', ...GMAIL_KEYS],
262
326
  rules: [
327
+ { name: 'template-search-allowed', tool: 'firecrawl_search', decision: 'allow' },
328
+ { name: 'template-read-pages-allowed', tool: 'firecrawl_scrape', decision: 'allow' },
263
329
  { name: 'template-notes-allowed', tool: 'hubspot_add_note', decision: 'allow' },
264
330
  { name: 'template-lead-updates-need-approval', tool: 'hubspot_update_contact', decision: 'ask' },
265
331
  { name: 'template-no-new-contacts', tool: 'hubspot_create_contact', decision: 'deny' },
@@ -282,7 +348,7 @@ Rules:
282
348
  risk: 'low',
283
349
  asksFirst: ['replying to the sender'],
284
350
  needs: [
285
- { kind: 'ability', name: 'lp-gmail', description: 'the shared inbox' },
351
+ { kind: 'ability', name: '@loopengine-co/gmail', description: 'the shared inbox' },
286
352
  { kind: 'app', name: 'GitHub', description: 'tasks, as GitHub issues' },
287
353
  ],
288
354
  agent: {
@@ -312,7 +378,7 @@ Rules:
312
378
  },
313
379
  },
314
380
  setup: {
315
- abilities: ['lp-gmail'],
381
+ abilities: ['@loopengine-co/gmail'],
316
382
  apps: [{ id: GITHUB_APP, title: 'GitHub', tools: ['search_issues', 'issue_write', 'add_issue_comment'] }],
317
383
  keys: [...GMAIL_KEYS],
318
384
  rules: [
@@ -333,19 +399,19 @@ Rules:
333
399
  category: 'Research',
334
400
  candidate: {
335
401
  title: 'Weekly research digest',
336
- summary: 'Every Monday morning, searches the web for news on the topics you choose and posts a short digest with links to Slack.',
402
+ summary: 'Every Monday morning, searches the web for news on the topics you choose and sends a short digest with links to Slack.',
337
403
  trigger: { kind: 'schedule', description: 'Mondays at 9:00', every: '7d' },
338
404
  hoursPerWeek: 2,
339
405
  fit: 6,
340
406
  risk: 'low',
341
407
  asksFirst: [],
342
408
  needs: [
343
- { kind: 'ability', name: 'lp-web-search', description: 'web search' },
344
- { kind: 'ability', name: 'lp-slack-notifier', description: 'posting the digest to Slack' },
409
+ { kind: 'app', name: 'Firecrawl', description: 'searching the web and reading pages' },
410
+ { kind: 'messaging', name: 'Slack', description: 'where each digest is sent (its Messaging tab)' },
345
411
  ],
346
412
  agent: {
347
413
  name: 'weekly-research-digest',
348
- systemPrompt: `You write a weekly research digest and post it to Slack.
414
+ systemPrompt: `You write a weekly research digest. Your answer is the digest: it's sent to Slack as it is.
349
415
 
350
416
  Topics to follow (edit this list):
351
417
  - [Your industry or market]
@@ -353,29 +419,29 @@ Topics to follow (edit this list):
353
419
  - [Technologies or regulations you care about]
354
420
 
355
421
  Each week:
356
- 1. Search the web with web_search for what happened in the last 7 days on each topic.
357
- 2. Open the most relevant pages with web_fetch and read them; don't rely on search snippets.
422
+ 1. Search the web with firecrawl_search for what happened in the last 7 days on each topic.
423
+ 2. Open the most relevant pages with firecrawl_scrape and read them; don't rely on search snippets.
358
424
  3. Write the digest: for each topic, up to three items, each with a one-sentence summary, why it matters to us, and the link.
359
425
  4. End with "Worth a closer look": the single most important item, and why.
360
- 5. Post it with post_to_slack.
426
+ 5. Answer with the digest alone, nothing before or after it.
361
427
 
362
428
  Rules:
363
429
  - Only include things published in the last 7 days, and give each item's date.
364
430
  - Prefer original sources over aggregators. Never invent a link.
365
431
  - If a topic had no real news, say so in one line.`,
366
- scheduleMessage: 'Write and post this week\'s research digest.',
367
- exampleMessage: 'Write and post this week\'s research digest.',
432
+ scheduleMessage: 'Write this week\'s research digest.',
433
+ exampleMessage: 'Write this week\'s research digest.',
368
434
  },
369
435
  },
370
436
  setup: {
371
- abilities: ['lp-web-search', 'lp-slack-notifier'],
372
- keys: [...WEB_SEARCH_KEYS, ...SLACK_KEYS],
437
+ abilities: [],
438
+ apps: [{ id: FIRECRAWL_APP, title: 'Firecrawl', tools: ['firecrawl_search', 'firecrawl_scrape'] }],
439
+ keys: [],
373
440
  rules: [
374
- { name: 'template-search-allowed', tool: 'web_search', decision: 'allow' },
375
- { name: 'template-fetch-allowed', tool: 'web_fetch', decision: 'allow' },
376
- { name: 'template-digest-post-allowed', tool: 'post_to_slack', decision: 'allow' },
441
+ { name: 'template-search-allowed', tool: 'firecrawl_search', decision: 'allow' },
442
+ { name: 'template-read-pages-allowed', tool: 'firecrawl_scrape', decision: 'allow' },
377
443
  ],
378
- schedule: { every: '7d', at: '09:00', weekday: 1, name: 'Weekly research digest', message: 'Write and post this week\'s research digest.' },
444
+ schedule: { every: '7d', at: '09:00', weekday: 1, name: 'Weekly research digest', message: 'Write this week\'s research digest.', notify: true },
379
445
  needsApprovals: false,
380
446
  notes: ['Edit "Topics to follow" at the top of its instructions (Overview tab)'],
381
447
  },
package/dist/index.d.ts CHANGED
@@ -108,7 +108,7 @@ export { AbilitySourceError, abilityOrigin, readAbilitySources, saveAbilitySourc
108
108
  export { describeAbilitySources, listPrivateAbilities, saveAbilitySourceFromConsole, removeAbilitySourceFromConsole } from './web/ability-sources-admin.js';
109
109
  export { readAbilityDetails, readToolDefinitions, type AbilityDetails } from './core/ability-details.js';
110
110
  export { loadMcpAbilityTools, mcpAbilityLoadError, closeMcpConnections, resolveMcpManifest, listMcpServerTools, isMcpAuthError, type McpServerManifest, type McpAuth, type InstalledMcpRecord, type McpToolInfo } from './core/mcp-abilities.js';
111
- export { searchMcpServers, getMcpServer, savedMcpServers, type McpDirectoryServer } from './core/mcp-directory.js';
111
+ export { searchMcpServers, getMcpServer, savedMcpServers, ownMcpServer, saveMcpServer, removeSavedMcpServer, McpServerInputError, type McpDirectoryServer } from './core/mcp-directory.js';
112
112
  export { generateMcpAbility, mcpAbilityNames, McpAbilityGenerationError, type GeneratedMcpAbility, type McpAbilityRequest } from './core/mcp-ability-generator.js';
113
113
  export { abilityDetails } from './web/ability-details-admin.js';
114
114
  export { WorkspaceAbilityError, listWorkspaceAbilities, addWorkspaceAbility, removeWorkspaceAbility, workspaceAbilitiesDir, type WorkspaceAbility } from './core/workspace-abilities.js';
package/dist/index.js CHANGED
@@ -98,7 +98,7 @@ export { AbilitySourceError, abilityOrigin, readAbilitySources, saveAbilitySourc
98
98
  export { describeAbilitySources, listPrivateAbilities, saveAbilitySourceFromConsole, removeAbilitySourceFromConsole } from './web/ability-sources-admin.js';
99
99
  export { readAbilityDetails, readToolDefinitions } from './core/ability-details.js';
100
100
  export { loadMcpAbilityTools, mcpAbilityLoadError, closeMcpConnections, resolveMcpManifest, listMcpServerTools, isMcpAuthError } from './core/mcp-abilities.js';
101
- export { searchMcpServers, getMcpServer, savedMcpServers } from './core/mcp-directory.js';
101
+ export { searchMcpServers, getMcpServer, savedMcpServers, ownMcpServer, saveMcpServer, removeSavedMcpServer, McpServerInputError } from './core/mcp-directory.js';
102
102
  export { generateMcpAbility, mcpAbilityNames, McpAbilityGenerationError } from './core/mcp-ability-generator.js';
103
103
  export { abilityDetails } from './web/ability-details-admin.js';
104
104
  export { WorkspaceAbilityError, listWorkspaceAbilities, addWorkspaceAbility, removeWorkspaceAbility, workspaceAbilitiesDir } from './core/workspace-abilities.js';
@@ -1,5 +1,16 @@
1
+ // Backs the Admin UI's official abilities search box: the npm packages
2
+ // under LoopEngine's @loopengine-co scope, tagged with the
3
+ // loopengine-ability keyword (core/official-abilities.ts). A fresh call on
4
+ // every search, no caching: this is an on-demand lookup the operator
5
+ // explicitly asked for, not data the tab needs hot the way
6
+ // gateway-tools.ts's own live-reconnect data is — same "only pay the
7
+ // live cost when actually asked for" split, just with nothing worth
8
+ // remembering afterward here.
9
+ import { isOfficialScope, unofficialAbilitiesAllowed } from '../core/official-abilities.js';
1
10
  const NPM_SEARCH_TIMEOUT_MS = 10000;
2
- const NPM_SEARCH_SIZE = 20;
11
+ // npm's most per page: the official ones are filtered out of everything
12
+ // tagged with the keyword, so ask for all of it.
13
+ const NPM_SEARCH_SIZE = 250;
3
14
  export async function searchPublicAbilities(query) {
4
15
  const text = query ? `keywords:loopengine-ability ${query}` : 'keywords:loopengine-ability';
5
16
  const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(text)}&size=${NPM_SEARCH_SIZE}`;
@@ -18,7 +29,12 @@ export async function searchPublicAbilities(query) {
18
29
  if (!res.ok)
19
30
  throw new Error(`npm registry search failed (HTTP ${res.status})`);
20
31
  const data = (await res.json());
21
- const results = (data.objects ?? []).map((o) => ({
32
+ // Only LoopEngine's own scope, unless unofficial abilities are turned
33
+ // on: anyone can publish with the keyword. (npm's scope: qualifier
34
+ // only ranks, it doesn't filter.)
35
+ const all = unofficialAbilitiesAllowed();
36
+ const official = (data.objects ?? []).filter((o) => all || isOfficialScope(o.package.name));
37
+ const results = official.map((o) => ({
22
38
  name: o.package.name,
23
39
  description: o.package.description ?? '',
24
40
  version: o.package.version,
@@ -728,7 +728,7 @@ ${consoleShellScript}
728
728
  '<span><a href="?agent=' + encodeURIComponent(agentName) + '&amp;tab=schedules#schedule-' + escapeHtml(x.taskId) + '" class="workflow-schedule-link" data-task="' + escapeHtml(x.taskId) + '" title="See it in the Schedules tab">' +
729
729
  escapeHtml(x.describe.charAt(0).toUpperCase() + x.describe.slice(1)) + '</a></span>' + state +
730
730
  (values ? '<span class="hint">' + values + '</span>' : '') +
731
- (x.notify ? '<span class="hint">results go to your messaging app</span>' : '') +
731
+ (x.notify ? '<span class="hint">messages you the result when it finishes</span>' : '') +
732
732
  '<span style="display:flex;gap:6px;margin-left:auto">' +
733
733
  (x.status === 'paused' ? '<button type="button" class="workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="resume">Resume</button>'
734
734
  : x.status === 'scheduled' ? '<button type="button" class="workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="pause">Pause</button>' : '') +
@@ -1023,7 +1023,7 @@ ${consoleShellScript}
1023
1023
  if (t.filter) rules.push('only when ' + escapeHtml(t.filter.path) + ' is ' + t.filter.values.map(escapeHtml).join(' or '));
1024
1024
  if (t.mapping) rules.push(Object.keys(t.mapping).map(function (p) { return escapeHtml(p) + ' ' + String.fromCharCode(8592) + ' ' + escapeHtml(t.mapping[p]); }).join(', '));
1025
1025
  if (t.payload_param) rules.push('the whole payload goes to ' + escapeHtml(t.payload_param));
1026
- if (t.notify) rules.push('sends each result to you');
1026
+ if (t.notify) rules.push('messages you the result when it finishes');
1027
1027
  if (t.callback_url) rules.push('reports to ' + escapeHtml(t.callback_url));
1028
1028
  // Made by the workflow planner without its secret: it turns on once
1029
1029
  // the sender's signing secret is added.
@@ -1069,7 +1069,7 @@ ${consoleShellScript}
1069
1069
  params.map(function (p) { return '<div class="workflow-param-row"><code class="wp-name">' + escapeHtml(p) + '</code><input name="map:' + escapeHtml(p) + '" placeholder="data.object.id" style="flex:1"></div>'; }).join('') + '</div>' +
1070
1070
  '<label>Or give the whole payload to <select name="payload_param"><option value="">(none)</option>' + params.map(function (p) { return '<option>' + escapeHtml(p) + '</option>'; }).join('') + '</select></label>'
1071
1071
  : '<p class="hint" style="margin:0">This workflow takes no parameters, so the payload isn' + String.fromCharCode(8217) + 't passed on. Add one in Edit to use it.</p>') +
1072
- '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each run' + String.fromCharCode(8217) + 's result to you through the agent' + String.fromCharCode(8217) + 's messaging app</label>' +
1072
+ '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Message me the result when it finishes <span class="hint">(through its Messaging tab: Slack, email, Lark or a webhook)</span></label>' +
1073
1073
  '<label>Post each run' + String.fromCharCode(8217) + 's result to <span class="hint">(optional, an https URL)</span><input name="callback_url" placeholder="https://example.com/hooks/loopengine"></label>' +
1074
1074
  '<div style="display:flex;gap:8px"><button type="submit" class="primary">Make the URL</button></div>' +
1075
1075
  '<div class="hint hook-form-result"></div></form>';
@@ -2431,8 +2431,8 @@ ${consoleShellScript}
2431
2431
  ? '<div><span class="le-pill bad">Workflow removed</span> <span class="hint">' + escapeHtml(t.workflow_id) + '</span></div>'
2432
2432
  : '<div class="schedule-result hint">' + escapeHtml(t.message) + '</div>') +
2433
2433
  (t.status !== 'cancelled' && t.status !== 'done'
2434
- ? '<label class="schedule-notify hint" title="Send each run&#39;s result through this agent&#39;s messaging connection (Messaging tab)"><input type="checkbox" class="schedule-notify-toggle" data-id="' + escapeHtml(t.task_id) + '"' + (t.notify ? ' checked' : '') + '> Send each result to you</label>'
2435
- : (t.notify ? '<div class="hint">Sent each result to you</div>' : '')) + '</td>' +
2434
+ ? '<label class="schedule-notify hint" title="Sent through its Messaging tab: Slack, email, Lark or a webhook"><input type="checkbox" class="schedule-notify-toggle" data-id="' + escapeHtml(t.task_id) + '"' + (t.notify ? ' checked' : '') + '> Message me the result when it finishes</label>'
2435
+ : (t.notify ? '<div class="hint">Messaged you the result when it finished</div>' : '')) + '</td>' +
2436
2436
  '<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(fmtScheduleTime(t.next_run_at)) + '</div>' : '') + '</td>' +
2437
2437
  '<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
2438
2438
  (t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Messaging</a></div>' : '') + '</td>' +
@@ -2474,7 +2474,7 @@ ${consoleShellScript}
2474
2474
  '<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
2475
2475
  '</div>' +
2476
2476
  '<label>Name <span class="hint">(optional)</span><input name="task_name" placeholder="Daily ticket summary"></label>' +
2477
- '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each result through this agent&#39;s messaging connection (set in the Messaging tab)</label>' +
2477
+ '<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Message me the result when it finishes <span class="hint">(through its Messaging tab: Slack, email, Lark or a webhook)</span></label>' +
2478
2478
  '<div style="display:flex;gap:8px"><button type="submit">Schedule</button></div>' +
2479
2479
  '<div id="scheduleResult" class="hint"></div>' +
2480
2480
  '</form></section>';
@@ -3040,7 +3040,7 @@ ${consoleShellScript}
3040
3040
  }
3041
3041
 
3042
3042
  // ---- Apps tab: what's already installed for this agent, a
3043
- // search box over public (npm, tagged loopengine-ability) packages,
3043
+ // search box over official (@loopengine-co on npm) abilities,
3044
3044
  // and a plain spec field for a private ability (a git URL, a local
3045
3045
  // file:../path, or a private registry name) — both installs go
3046
3046
  // through the same POST .../abilities, since installAbility itself
@@ -3085,7 +3085,7 @@ ${consoleShellScript}
3085
3085
  '</tr>';
3086
3086
  }
3087
3087
 
3088
- // A public (npm) or private ability not installed here yet: its
3088
+ // An official or private ability not installed here yet: its
3089
3089
  // details show the permissions it ships with.
3090
3090
  function renderAbilitySearchResultRow(pkg, installedNames, spec) {
3091
3091
  var installSpec = spec || pkg.name;
@@ -3108,15 +3108,15 @@ ${consoleShellScript}
3108
3108
  : '<p class="hint">Nothing added to this agent yet.</p>';
3109
3109
 
3110
3110
  // What this agent has first; then the workspace's connected apps (each
3111
- // with the tools this agent gets), then public and private abilities.
3111
+ // with the tools this agent gets), then official and private abilities.
3112
3112
  return '<h3 style="justify-content:flex-start;align-items:baseline;gap:8px">Installed <span class="hint" style="font-weight:400">the apps and abilities this agent has</span></h3>' + installedHtml +
3113
3113
  // Installing by name or spec is on the console's Abilities page.
3114
3114
  '<p id="abilityInstallStatus" class="hint"></p>' +
3115
3115
  '<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Apps <span class="hint" style="font-weight:400">connected in this workspace; choose which of their tools this agent gets, and each one' + String.fromCharCode(8217) + 's permission</span></h3>' +
3116
3116
  '<div id="agentApps" style="margin-bottom:32px"><p class="hint">Loading&hellip;</p></div>' +
3117
- '<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Public abilities <span class="hint" style="font-weight:400">npm packages tagged loopengine-ability</span></h3>' +
3117
+ '<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Official abilities <span class="hint" style="font-weight:400">published by LoopEngine</span></h3>' +
3118
3118
  '<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
3119
- '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search public abilities" style="flex:1">' +
3119
+ '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search official abilities" style="flex:1">' +
3120
3120
  '<button type="submit">Search</button>' +
3121
3121
  '</form>' +
3122
3122
  '<div id="abilitySearchResults" style="margin-bottom:32px"></div>' +
@@ -3334,7 +3334,7 @@ ${consoleShellScript}
3334
3334
  var results = result.body.results || [];
3335
3335
  resultsEl.innerHTML = results.length
3336
3336
  ? '<table><thead><tr><th>Package</th><th>Description</th><th></th></tr></thead><tbody>' + results.map(function (pkg) { return renderAbilitySearchResultRow(pkg, installedNames); }).join('') + '</tbody></table>'
3337
- : '<p class="hint">No public abilities found.</p>';
3337
+ : '<p class="hint">No official abilities found.</p>';
3338
3338
  var installBtns = resultsEl.querySelectorAll('.ability-install-btn');
3339
3339
  for (var i = 0; i < installBtns.length; i++) {
3340
3340
  installBtns[i].addEventListener('click', function (ev2) {
@@ -15,7 +15,7 @@
15
15
  // leShell.wireModelPicker(form) model dropdown for a provider select
16
16
  // leShell.loadProviderKeys() Promise of {provider: key saved?}, or null
17
17
  // leShell.applyProviderKeys(form, keys, keep) only providers with a key
18
- // leShell.abilityOriginBadge(origin) Public/Private badge for an ability
18
+ // leShell.abilityOriginBadge(origin) Official/Public/Private badge for an ability
19
19
  // leShell.toggleAbilityDetails(btn) an ability's tools, skills and settings
20
20
  // Same embedded-string conventions as the pages themselves: plain
21
21
  // string concatenation and no backslashes inside the script, so nothing
@@ -360,11 +360,12 @@ export const consoleShellScript = `<script>
360
360
  return decision ? '<span class="le-pill ' + decision + '">' + DECISION_LABELS[decision] + '</span>' : '<span class="hint">No rule</span>';
361
361
  }
362
362
 
363
- // Public (npm) or Private, with where it came from on hover.
363
+ // Official (@loopengine-co), Public (other npm) or Private, with where
364
+ // it came from on hover.
364
365
  function abilityOriginBadge(origin) {
365
366
  if (!origin) return '';
366
367
  return '<span class="le-pill ' + (origin.kind === 'private' ? 'accent' : '') + '" title="' + esc(origin.label) + '">' +
367
- (origin.kind === 'private' ? 'Private' : 'Public') + '</span> <span class="hint">' + esc(origin.label) + '</span>';
368
+ (origin.kind === 'private' ? 'Private' : origin.kind === 'official' ? 'Official' : 'Public') + '</span> <span class="hint">' + esc(origin.label) + '</span>';
368
369
  }
369
370
 
370
371
 
@@ -251,7 +251,7 @@ ${consoleShellScript}
251
251
  }
252
252
 
253
253
  // ---- Abilities section ----
254
- // How agents reach outside apps. Public abilities (npm) are browsed
254
+ // How agents reach outside apps. Official abilities (npm) are browsed
255
255
  // here and installed from an agent's own Apps tab. A private one
256
256
  // (from the sources in Settings) is installed here into the workspace
257
257
  // (core/workspace-abilities.ts), then added to any agent from its tab.
@@ -271,9 +271,9 @@ ${consoleShellScript}
271
271
  function renderAbilities() {
272
272
  var content = document.getElementById('abilitiesContent');
273
273
  content.innerHTML = '<section>' +
274
- '<h4>Public <span class="hint">npm packages tagged loopengine-ability; add one to an agent from its Apps tab</span></h4>' +
274
+ '<h4>Official <span class="hint">published by LoopEngine; add one to an agent from its Apps tab</span></h4>' +
275
275
  '<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
276
- '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search public abilities" style="flex:1">' +
276
+ '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search official abilities" style="flex:1">' +
277
277
  '<button type="submit">Search</button>' +
278
278
  '</form>' +
279
279
  '<div id="abilitySearchResults"><p class="hint">Loading&hellip;</p></div>' +
@@ -443,7 +443,7 @@ ${consoleShellScript}
443
443
  }
444
444
 
445
445
  // Linked from an agent's Apps tab (/config?section=abilities#private-abilities):
446
- // once every list has loaded, so the public results don't push it down.
446
+ // once every list has loaded, so the official results don't push it down.
447
447
  var scrolledToPrivate = false;
448
448
  function scrollToPrivateIfLinked() {
449
449
  if (scrolledToPrivate || location.hash !== '#private-abilities' || !lastPrivate || !workspaceAbilities || lastSearch.q === null) return;
@@ -547,6 +547,8 @@ ${consoleShellScript}
547
547
  // Open tool panels, by ability: { tools, filter, error, busy }.
548
548
  var openTools = {};
549
549
  var appSearch = { q: '', servers: null, error: '' };
550
+ // The "Add your own server" form: open, and what's typed in it.
551
+ var ownForm = { open: false };
550
552
 
551
553
  function appLetter(title) {
552
554
  return '<span class="app-letter" aria-hidden="true">' + escapeHtml((title || '?').charAt(0).toUpperCase()) + '</span>';
@@ -572,6 +574,37 @@ ${consoleShellScript}
572
574
  '<p class="hint app-msg" style="margin:0"></p></form>';
573
575
  }
574
576
 
577
+ // Your company's own servers (mcp-servers.yml): each connects like any
578
+ // app; a saved one not connected can be connected again or removed.
579
+ function renderOwnServers() {
580
+ var saved = apps.saved || [];
581
+ var rows = saved.map(function (s) {
582
+ var connected = apps.connected.some(function (c) { return c.url === s.url; });
583
+ return '<div class="app-row" data-app="' + escapeHtml(s.id) + '"><div class="app-head">' + appLetter(s.title) + '</div>' +
584
+ '<div class="app-row-main"><div><strong>' + escapeHtml(s.title) + '</strong> <span class="badge badge-default">Private</span></div>' +
585
+ '<div class="hint">' + escapeHtml(s.url) + (s.description ? ' &middot; ' + escapeHtml(s.description) : '') + (s.header ? ' &middot; key sent in ' + escapeHtml(s.header) : '') + '</div></div>' +
586
+ '<div class="app-slot">' + (connected
587
+ ? '<span class="badge badge-custom">Connected</span>'
588
+ : '<button type="button" class="app-connect" data-id="' + escapeHtml(s.id) + '"' + (apps.canConnect ? '' : ' disabled') + '>Connect</button> <button type="button" class="delete-btn app-forget" data-id="' + escapeHtml(s.id) + '"' + (apps.canConnect ? '' : ' disabled') + '>Remove</button>') + '</div></div>';
589
+ }).join('');
590
+ var form = ownForm.open
591
+ ? '<form id="ownServerForm" class="app-form" style="max-width:560px">' +
592
+ '<label>Name<input name="title" required maxlength="60" placeholder="Acme CRM"></label>' +
593
+ '<label>Address<input name="url" type="url" required placeholder="https://mcp.yourcompany.com/mcp"></label>' +
594
+ '<label>What it does <span class="hint">(optional)</span><input name="description" maxlength="200" placeholder="Our customers, orders and support tickets"></label>' +
595
+ '<label>API key <span class="hint">(if it needs one; saved in this workspace' + String.fromCharCode(8217) + 's settings)</span><input name="key" type="password" autocomplete="off"></label>' +
596
+ '<label>Header for the key <span class="hint">(optional; empty sends it as Authorization: Bearer ' + String.fromCharCode(8230) + ')</span><input name="header" placeholder="X-Api-Key" pattern="[A-Za-z0-9-]+"></label>' +
597
+ '<div class="app-foot"><button type="submit">Connect</button><button type="button" class="own-cancel">Cancel</button></div>' +
598
+ '<p class="hint app-msg" style="margin:0"></p></form>'
599
+ : '<button type="button" class="own-add"' + (apps.canConnect ? '' : ' disabled') + '>Add your own server</button>';
600
+ return '<section id="own-servers"><h3>Your own servers</h3>' +
601
+ '<p class="hint">Your company' + String.fromCharCode(8217) + 's own MCP server, by its address: it connects like any app, and only this workspace sees it. Servers that only offer a sign-in (OAuth) can' + String.fromCharCode(8217) + 't be connected yet.</p>' +
602
+ (rows ? '<div class="app-list" style="margin-bottom:10px">' + rows + '</div>' : '') + form + '</section>';
603
+ }
604
+
605
+ // By name: form.title is the form's own title attribute.
606
+ function ownField(form, name) { return form.querySelector('[name=' + name + ']').value; }
607
+
575
608
  function renderApps() {
576
609
  var el = document.getElementById('appsContent');
577
610
  if (!apps) return;
@@ -601,6 +634,7 @@ ${consoleShellScript}
601
634
  }).join('') + '</div>' +
602
635
  '<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
636
  '</section>' +
637
+ renderOwnServers() +
604
638
  '<section><h3>More apps</h3>' +
605
639
  '<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
640
  '<div id="appSearchResults">' + renderAppSearch() + '</div>' +
@@ -716,6 +750,26 @@ ${consoleShellScript}
716
750
  return;
717
751
  }
718
752
  if (t.classList.contains('app-cancel')) { renderApps(); return; }
753
+ if (t.classList.contains('own-add')) {
754
+ ownForm.open = true;
755
+ renderApps();
756
+ document.querySelector('#ownServerForm input[name=title]').focus();
757
+ return;
758
+ }
759
+ if (t.classList.contains('own-cancel')) { ownForm.open = false; renderApps(); return; }
760
+ if (t.classList.contains('app-forget')) {
761
+ if (t.dataset.confirming !== '1') { t.dataset.confirming = '1'; t.textContent = 'Click again to remove'; return; }
762
+ t.disabled = true;
763
+ fetch('/config/apps/servers?id=' + encodeURIComponent(t.dataset.id), { method: 'DELETE' })
764
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
765
+ .then(function (r) {
766
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
767
+ apps = r.body;
768
+ renderApps();
769
+ })
770
+ .catch(function (err) { t.disabled = false; t.textContent = 'Could not remove: ' + err.message; });
771
+ return;
772
+ }
719
773
  if (t.classList.contains('app-disconnect')) {
720
774
  if (t.dataset.confirming !== '1') { t.dataset.confirming = '1'; t.textContent = 'Click again to disconnect'; return; }
721
775
  t.disabled = true;
@@ -760,6 +814,28 @@ ${consoleShellScript}
760
814
  .catch(function (err) { appSearch.error = 'Could not search: ' + err.message; document.getElementById('appSearchResults').innerHTML = renderAppSearch(); });
761
815
  return;
762
816
  }
817
+ if (form.id === 'ownServerForm') {
818
+ var ownBtn = form.querySelector('button[type=submit]');
819
+ var ownMsg = form.querySelector('.app-msg');
820
+ ownBtn.disabled = true;
821
+ ownMsg.className = 'hint app-msg';
822
+ ownMsg.textContent = 'Connecting' + String.fromCharCode(8230) + ' it asks the server for its tools and sets their permissions, which can take a minute.';
823
+ fetch('/config/apps/servers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: ownField(form, 'title'), url: ownField(form, 'url'), description: ownField(form, 'description'), key: ownField(form, 'key'), header: ownField(form, 'header') }) })
824
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
825
+ .then(function (r) {
826
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
827
+ apps = r.body;
828
+ ownForm.open = false;
829
+ if (r.body.connectedAbility) loadTools(r.body.connectedAbility, false);
830
+ else renderApps();
831
+ })
832
+ .catch(function (err) {
833
+ ownBtn.disabled = false;
834
+ ownMsg.className = 'error app-msg';
835
+ ownMsg.textContent = err.message;
836
+ });
837
+ return;
838
+ }
763
839
  if (!form.dataset.connect) return;
764
840
  var btn = form.querySelector('button[type=submit]');
765
841
  var msg = form.querySelector('.app-msg');
@@ -805,12 +881,12 @@ ${consoleShellScript}
805
881
  rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
806
882
  '</tbody></table></section>' +
807
883
  (d.timeZone ? '<section><h3>Time zone</h3><div class="source" id="timeZoneBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' : '') +
808
- '<section><h3>Private ability sources</h3><p class="muted" style="margin:0 0 10px">Where your own abilities come from, listed under Private on the Abilities page next to the public ones on npm.</p><div class="source" id="abilitySourcesBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' +
884
+ '<section><h3>Private ability sources</h3><p class="muted" style="margin:0 0 10px">Where your own abilities come from, listed under Private on the Abilities page next to LoopEngine&#39;s official ones.</p><div class="source" id="abilitySourcesBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' +
809
885
  '<section><h3>Updates</h3><div class="source" id="upgradeBox"><p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p></div></section>';
810
886
  }
811
887
 
812
888
  // ---- Private ability sources (Settings) ----
813
- // Where this project's own abilities come from, next to the public ones
889
+ // Where this project's own abilities come from, next to the official ones
814
890
  // on npm: a GitHub org or user, or a private npm registry
815
891
  // (core/ability-sources.ts). There's no folder kind here: this server
816
892
  // may run in the cloud, so local files are uploaded on the Abilities
@@ -15,6 +15,8 @@ import { ActauthRuleExistsError, addActauthRule, applyPlannedRules, setDefaultDe
15
15
  import { setAgentEnvVar } from './env-admin.js';
16
16
  import { freeAgentName } from './interview-admin.js';
17
17
  import { writeSkill } from './skills-admin.js';
18
+ import { agentDir } from '../core/gateway-tools.js';
19
+ import { setWorkflowFields } from '../core/workflows.js';
18
20
  /** What a template's schedule runs (its workflow, when it comes from one,
19
21
  * or its message), and whether it sends each result. */
20
22
  function scheduleRuns(schedule) {
@@ -84,7 +86,7 @@ export function previewTemplate(id, deps) {
84
86
  // A key set for the whole project already counts for a new agent.
85
87
  keys: keyGroups(template).map((names) => ({ names, set: names.some((n) => Boolean(envValue(name, n))) })),
86
88
  settings: setup.env ?? {},
87
- schedule: setup.schedule ? `${describeTemplateSchedule(setup.schedule)} (created paused)` : null,
89
+ schedule: setup.schedule ? `${describeTemplateSchedule(setup.schedule)} (created paused)${setup.schedule.notify ? '; it messages you the result when it finishes, through its Messaging tab: Slack, email, Lark or a webhook' : ''}` : null,
88
90
  needsApprovals: setup.needsApprovals,
89
91
  notes: setup.notes ?? [],
90
92
  };
@@ -104,8 +106,19 @@ export function templateChecklist(template, name, deps, failed = [], appsToConne
104
106
  for (const group of missing.filter((g) => g.length > 1)) {
105
107
  items.push({ label: `Add one of ${group.slice(0, -1).join(', ')} or ${group.at(-1)}`, href: tab('env') });
106
108
  }
107
- if (template.setup.needsApprovals && !deps.messagingConfigured(name)) {
108
- items.push({ label: 'Choose where approval requests go: Slack, email, Lark or a webhook', href: tab('messaging') });
109
+ // A schedule or webhook that sends each result needs the same connection.
110
+ const webhookWorkflows = (template.setup.skills ?? []).filter((s) => s.workflow?.webhook);
111
+ const sendsResults = Boolean(template.setup.schedule?.notify) || webhookWorkflows.length > 0;
112
+ if ((template.setup.needsApprovals || sendsResults) && !deps.messagingConfigured(name)) {
113
+ items.push({
114
+ label: template.setup.needsApprovals
115
+ ? `Choose where approval requests${sendsResults ? ' and results' : ''} go: Slack, email, Lark or a webhook`
116
+ : 'Choose where its results go: Slack, email, Lark or a webhook',
117
+ href: tab('messaging'),
118
+ });
119
+ }
120
+ for (const skill of webhookWorkflows) {
121
+ items.push({ label: `Add a webhook to the "${skill.workflow.title}" workflow with "Message me the result when it finishes" on, then paste its URL into the app that sends the alerts`, href: tab('workflows') });
109
122
  }
110
123
  for (const note of template.setup.notes ?? [])
111
124
  items.push({ label: note });
@@ -179,8 +192,11 @@ export async function createFromTemplateObject(template, deps) {
179
192
  // none twice, a stricter one placed so it counts, none loosened.
180
193
  applyPlannedRules(name, setup.rules.map((r) => ({ name: r.name, tool: r.tool, decision: r.decision })));
181
194
  // 3. Its own skills and non-secret settings.
182
- for (const skill of setup.skills ?? [])
195
+ for (const skill of setup.skills ?? []) {
183
196
  writeSkill(name, skill.id, { description: skill.description, body: skill.body });
197
+ if (skill.workflow)
198
+ setWorkflowFields(agentDir(name), skill.id, { title: skill.workflow.title, instructions: skill.body, ...(skill.workflow.parameters ? { parameters: skill.workflow.parameters } : {}) });
199
+ }
184
200
  for (const [variable, value] of Object.entries(setup.env ?? {}))
185
201
  await setAgentEnvVar(name, variable, value);
186
202
  // 4. Load it, now that everything is in place.
@@ -75,7 +75,7 @@ ${consoleShellScript}
75
75
  '<div class="meta">' +
76
76
  '<div><strong>Runs:</strong> ' + esc(t.trigger) + '</div>' +
77
77
  (t.asksFirst.length ? '<div><strong>Asks you first:</strong> ' + esc(t.asksFirst.join(', ')) + '</div>' : '') +
78
- '<div><strong>Installs:</strong> ' + t.abilities.map(function (a) { return '<code>' + esc(a) + '</code>'; }).join(' ') + '</div>' +
78
+ (t.abilities.length || (t.apps || []).length ? '<div><strong>Uses:</strong> ' + t.abilities.map(function (a) { return '<code>' + esc(a) + '</code>'; }).concat((t.apps || []).map(function (a) { return esc(a); })).join(' ') + '</div>' : '') +
79
79
  '</div>' +
80
80
  '<div class="row"><button type="button" class="primary tp-use">Use this template</button></div>' +
81
81
  '<div class="tp-slot"></div></div>';
@@ -108,17 +108,18 @@ ${consoleShellScript}
108
108
  slot.innerHTML = '<div class="tp-preview">' +
109
109
  '<div><strong>Agent:</strong> <code>' + esc(p.name) + '</code></div>' +
110
110
  '<div><strong>Instructions</strong> <span class="hint">(edit them later in its Overview tab)</span><pre>' + esc(p.systemPrompt) + '</pre></div>' +
111
- '<div><strong>Installs</strong> <span class="hint">(abilities: their tools, skills and permission rules)</span>' +
111
+ (p.abilities.length ? '<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
- (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>' +
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
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>' : '') +
115
115
  '<div><strong>Permissions</strong> <span class="hint">(anything not listed asks you first)</span>' +
116
116
  listHtml(p.rules.map(function (rule) { return '<li><code>' + esc(rule.tool) + '</code>: ' + esc(DECISIONS[rule.decision] || rule.decision) + '</li>'; })) + '</div>' +
117
- '<div><strong>Keys it needs</strong>' +
117
+ (p.skills && p.skills.length ? '<div><strong>Skills it starts with</strong> <span class="hint">(edit them later in Advanced ' + String.fromCharCode(8594) + ' Skills)</span>' + listHtml(p.skills.map(function (sk) { return '<li><code>' + esc(sk) + '</code></li>'; })) + '</div>' : '') +
118
+ (p.keys.length ? '<div><strong>Keys it needs</strong>' +
118
119
  listHtml(p.keys.map(function (k) {
119
120
  var names = k.names.length > 1 ? 'one of ' + k.names.join(', ') : k.names[0];
120
121
  return '<li>' + esc(names) + (k.set ? ' <span class="hint">' + CHECK + ' already set</span>' : '') + '</li>';
121
- })) + '</div>' +
122
+ })) + '</div>' : '') +
122
123
  (settings.length ? '<div><strong>Settings filled in:</strong> ' + settings.map(function (s) { return '<code>' + esc(s) + '=' + esc(p.settings[s]) + '</code>'; }).join(', ') + '</div>' : '') +
123
124
  (p.schedule ? '<div><strong>Schedule:</strong> ' + esc(p.schedule) + '</div>' : '') +
124
125
  (p.needsApprovals ? '<div><strong>Approvals:</strong> requests reach you wherever its Messaging tab sends them (Slack, email, Lark or a webhook).</div>' : '') +
@@ -263,7 +263,7 @@ ${consoleShellScript}
263
263
  '<p class="hint" style="margin:0"><strong>Starts:</strong> ' + esc(p.trigger.description) + (p.trigger.kind === 'schedule' ? ' (created paused; you turn it on)' : '') + '</p>' +
264
264
  webhookPlanHtml(p) +
265
265
  (p.trigger.kind === 'schedule' || p.trigger.kind === 'webhook'
266
- ? '<label class="wf-notify"><input type="checkbox" id="wfNotify"' + (p.notify && p.notify.on ? ' checked' : '') + '> Send each run' + String.fromCharCode(8217) + 's result to you through the agent' + String.fromCharCode(8217) + 's messaging app</label>' +
266
+ ? '<label class="wf-notify"><input type="checkbox" id="wfNotify"' + (p.notify && p.notify.on ? ' checked' : '') + '> Message me the result when it finishes <span class="hint">(through its Messaging tab: Slack, email, Lark or a webhook)</span></label>' +
267
267
  (p.notify && p.notify.reason ? '<p class="hint" style="margin:0 0 0 24px">' + esc(p.notify.reason) + '</p>' : '')
268
268
  : '') +
269
269
  paramsSectionHtml(p) +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -48,6 +48,8 @@
48
48
  "dist/core/usage.d.ts",
49
49
  "dist/core/run-events.js",
50
50
  "dist/core/run-events.d.ts",
51
+ "dist/core/official-abilities.js",
52
+ "dist/core/official-abilities.d.ts",
51
53
  "dist/core/email-senders.js",
52
54
  "dist/core/email-senders.d.ts",
53
55
  "dist/core/notifier-file.js",