create-loopengine 0.1.77 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.77",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  // file's call — see AgentConfig.sessionIdFor and defaultSessionIdFor below.
30
30
  import { execFile } from 'node:child_process'
31
31
  import { randomUUID, timingSafeEqual } from 'node:crypto'
32
- import { createReadStream, existsSync } from 'node:fs'
32
+ import { createReadStream, existsSync, readFileSync, rmSync } from 'node:fs'
33
33
  import { stat } from 'node:fs/promises'
34
34
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
35
35
  import { extname, join, resolve as resolvePath, sep } from 'node:path'
@@ -183,6 +183,7 @@ import {
183
183
  removeAbility,
184
184
  listInstalledAbilities,
185
185
  backfillDependencies,
186
+ configureAgentMcpTools,
186
187
  AbilityAlreadyInstalledError,
187
188
  AbilityCollisionError,
188
189
  AbilityManifestError,
@@ -195,13 +196,44 @@ import {
195
196
  removeAbilitySourceFromConsole,
196
197
  saveAbilitySourceFromConsole,
197
198
  abilityDetails,
199
+ WorkflowError,
200
+ createWorkflow,
201
+ forgetWorkflow,
202
+ getPlanJob,
203
+ listAgentWorkflows,
204
+ planWorkflow,
205
+ runWorkflowNow,
206
+ startPlanJob,
207
+ type WorkflowAgentInfo,
208
+ type WorkflowMcpDeps,
209
+ workflowPageHtml,
198
210
  MAX_UPLOAD_BYTES,
199
211
  installUploadedAbility,
200
212
  installWorkspaceAbility,
201
213
  type AbilityUpload,
202
214
  WorkspaceAbilityError,
215
+ addWorkspaceAbility,
203
216
  listWorkspaceAbilities,
204
217
  removeWorkspaceAbility,
218
+ getMcpServer,
219
+ searchMcpServers,
220
+ isMcpAuthError,
221
+ listMcpServerTools,
222
+ resolveMcpManifest,
223
+ McpAbilityGenerationError,
224
+ generateMcpAbility,
225
+ mcpAbilityNames,
226
+ readAbilityDetails,
227
+ AppToolsError,
228
+ appCatalog,
229
+ appServer,
230
+ appTools,
231
+ connectedApps,
232
+ isAppAbility,
233
+ loadAppIcons,
234
+ refreshAppTools,
235
+ setAppDefault,
236
+ type AgentAppTools,
205
237
  AbilitySourceError,
206
238
  abilityOrigin,
207
239
  readAbilitySources,
@@ -522,7 +554,7 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
522
554
  skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 2000,
523
555
  skillsDirs: skillsDirs,
524
556
  // How many abilities are installed — just the count, for the console's
525
- // tab badge; the Abilities tab's own GET .../abilities has the detail
557
+ // tab badge; the Apps tab's own GET .../abilities has the detail
526
558
  // (and its slower npm version lookups).
527
559
  abilityCount: listInstalledAbilities(config.name).length,
528
560
  skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
@@ -1014,6 +1046,418 @@ async function handleInterviews(req: IncomingMessage, res: ServerResponse, pathn
1014
1046
  }
1015
1047
  }
1016
1048
 
1049
+ // "Describe a workflow" (loopengine's web/workflow-admin.ts): what the planner and the
1050
+ // checks know about each agent: its tools, abilities, rules and primary
1051
+ // messaging app.
1052
+ async function workflowAgentInfo(): Promise<WorkflowAgentInfo[]> {
1053
+ return Promise.all(
1054
+ listAgents().flatMap((name) => {
1055
+ const entry = getEntry(name)
1056
+ if (!entry) return []
1057
+ return [(async (): Promise<WorkflowAgentInfo> => {
1058
+ const { rules, defaultDecision } = readActauthConfig(name)
1059
+ // Abilities by default: gateway tools (Composio and the like) are
1060
+ // left out, so a workflow is never built on one.
1061
+ let tools: string[] = []
1062
+ let gateway = new Set<string>()
1063
+ try {
1064
+ gateway = new Set((await loadGatewayToolsFromDir(agentDir(name))).map((t) => t.name))
1065
+ const own = entry.config.tools ?? (await loadDefaultTools(entry.config))
1066
+ const all = [...own, ...(await loadSubagentAsTools(entry.config))]
1067
+ tools = [...new Set(all.map((t) => t.name))].filter((t) => !gateway.has(t))
1068
+ } catch {}
1069
+ let abilities: string[] = []
1070
+ try {
1071
+ abilities = listInstalledAbilities(name).map((a) => a.name)
1072
+ } catch {}
1073
+ let messaging: string | null = null
1074
+ try {
1075
+ const n = describeAgentNotifier(entry.config, defaultWebhook)
1076
+ messaging = n.source === 'none' ? null : (n.primary ?? n.channel ?? null)
1077
+ } catch {}
1078
+ const decided = (d: unknown): d is 'allow' | 'ask' | 'deny' => d === 'allow' || d === 'ask' || d === 'deny'
1079
+ return {
1080
+ name,
1081
+ tools,
1082
+ abilities,
1083
+ rules: rules.flatMap((r) => (decided(r.decision) && !gateway.has(r.tool) ? [{ tool: r.tool, decision: r.decision, scope: r.scope }] : [])),
1084
+ defaultDecision: decided(defaultDecision) ? defaultDecision : 'ask',
1085
+ messaging,
1086
+ }
1087
+ })()]
1088
+ }),
1089
+ )
1090
+ }
1091
+
1092
+ // Steps no ability can do can use an app's MCP server
1093
+ // (core/mcp-directory.ts), made into a private ability on Create.
1094
+ const workflowMcp: WorkflowMcpDeps = {
1095
+ getServer: getMcpServer,
1096
+ // The workspace ability already using the server at `url`.
1097
+ existingFor(url) {
1098
+ for (const found of listWorkspaceAbilities()) {
1099
+ try {
1100
+ const manifest = JSON.parse(readFileSync(join(found.spec.replace(/^file:/, ''), 'loopengine.ability.json'), 'utf8')) as { mcp?: { url?: string } }
1101
+ if (found.source !== `mcp:${url}` && manifest.mcp?.url !== url) continue
1102
+ return { spec: found.spec, tools: resolveMcpManifest(found.name, manifest.mcp).tools }
1103
+ } catch {}
1104
+ }
1105
+ return undefined
1106
+ },
1107
+ keyValue: (name) => process.env[name],
1108
+ }
1109
+
1110
+ /** A server's tools for the planner, or why they can't be seen yet. */
1111
+ async function workflowMcpServerTools(id: string): Promise<unknown> {
1112
+ const server = await getMcpServer(id)
1113
+ if (!server) return `No MCP server ${id}.`
1114
+ const { keyEnv } = mcpAbilityNames(server)
1115
+ const key = process.env[keyEnv]
1116
+ try {
1117
+ const tools = await listMcpServerTools(
1118
+ { url: server.url, ...(server.transport === 'sse' ? { transport: 'sse' as const } : {}), auth: key ? { type: 'key', env: keyEnv, ...(server.auth ?? {}) } : { type: 'none' } },
1119
+ { get: (n) => (n === keyEnv ? key : undefined), require: (n) => { if (n === keyEnv && key) return key; throw new Error(`${n} is not set`) } },
1120
+ )
1121
+ return { server: server.title, tools: tools.slice(0, 80).map((t) => ({ name: t.name, description: t.description.split('\n')[0].slice(0, 200), ...(t.readOnly !== undefined ? { readOnly: t.readOnly } : {}) })) }
1122
+ } catch (err) {
1123
+ const message = err instanceof Error ? err.message : String(err)
1124
+ return isMcpAuthError(err)
1125
+ ? `${server.title} needs a key before its tools can be seen. Plan its steps by what they do (leave "tool" empty); the person adds the key on the plan, and its tools are picked on Create.`
1126
+ : `Couldn't reach ${server.title}: ${message.slice(0, 200)}`
1127
+ }
1128
+ }
1129
+
1130
+ /** Each agent's tools from the app `abilityName`, with the permission
1131
+ * each has there (its first rule for the tool). */
1132
+ function agentAppTools(abilityName: string): AgentAppTools {
1133
+ const out: AgentAppTools = {}
1134
+ for (const agent of listAgents()) {
1135
+ let record: ReturnType<typeof listInstalledAbilities>[number] | undefined
1136
+ try {
1137
+ record = listInstalledAbilities(agent).find((a) => a.name === abilityName && a.mcp)
1138
+ } catch {}
1139
+ if (!record?.mcp) continue
1140
+ const rules = readActauthConfig(agent).rules
1141
+ const decided = (d: unknown): d is 'allow' | 'ask' | 'deny' => d === 'allow' || d === 'ask' || d === 'deny'
1142
+ out[agent] = record.mcp.tools.map((t) => {
1143
+ const d = rules.find((r) => r.tool === t.name)?.decision
1144
+ return { remote: t.remote, decision: decided(d) ? d : 'ask' }
1145
+ })
1146
+ }
1147
+ return out
1148
+ }
1149
+
1150
+ /** Adds more of an app's tools to an agent that has it, each at the
1151
+ * app's default permission, keeping the agent's current ones. */
1152
+ function addAgentAppTools(agent: string, spec: string, tools: string[]): void {
1153
+ const dir = spec.replace(/^file:/, '')
1154
+ const name = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name: string }).name
1155
+ const current = agentAppTools(name)[agent] ?? []
1156
+ const defaults = readAbilityDetails(dir).tools
1157
+ const manifest = JSON.parse(readFileSync(join(dir, 'loopengine.ability.json'), 'utf8')) as { mcp?: unknown }
1158
+ const all = resolveMcpManifest(name, manifest.mcp).tools
1159
+ const choice = [...current]
1160
+ for (const remote of tools) {
1161
+ if (choice.some((c) => c.remote === remote)) continue
1162
+ const installed = all.find((t) => t.remote === remote)?.name
1163
+ const d = defaults.find((t) => t.name === installed)?.decision
1164
+ choice.push({ remote, decision: d === 'allow' || d === 'deny' ? d : 'ask' })
1165
+ }
1166
+ configureAgentMcpTools(agent, name, dir, choice)
1167
+ }
1168
+
1169
+ /** For a template or workflow: the connected app's workspace spec, or
1170
+ * undefined when it isn't connected. Its tools are chosen at install. */
1171
+ async function prepareWorkspaceApp(id: string): Promise<string | undefined> {
1172
+ const server = await appServer(id)
1173
+ return describeApps().connected.find((c) => c.id === id || (server && c.url === server.url))?.spec
1174
+ }
1175
+
1176
+ function describeApps(): { apps: ReturnType<typeof appCatalog>; connected: ReturnType<typeof connectedApps>; canConnect: boolean } {
1177
+ const agentAbilities: Record<string, string[]> = {}
1178
+ for (const name of listAgents()) {
1179
+ try {
1180
+ agentAbilities[name] = listInstalledAbilities(name).map((a) => a.name)
1181
+ } catch {}
1182
+ }
1183
+ const connected = connectedApps(listWorkspaceAbilities(), agentAbilities, (name) => process.env[name])
1184
+ return { apps: appCatalog(connected), connected, canConnect: secretsProtected }
1185
+ }
1186
+
1187
+ async function handleAgentApp(req: IncomingMessage, res: ServerResponse, agent: string, ability: string): Promise<void> {
1188
+ const json = (status: number, body: unknown): void => {
1189
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
1190
+ }
1191
+ try {
1192
+ if (!getEntry(agent)) return json(404, { error: `unknown agent '${agent}'` })
1193
+ const app = describeApps().connected.find((c) => c.ability === ability)
1194
+ if (!app) return json(400, { error: 'That app isn\'t connected.' })
1195
+ const dir = app.spec.replace(/^file:/, '')
1196
+ const envValue = (name: string) => process.env[name]
1197
+ const view = async () => {
1198
+ const mine = agentAppTools(ability)[agent]
1199
+ const all = await appTools(app.spec, envValue, {})
1200
+ return {
1201
+ app: { title: app.title, ability },
1202
+ installed: Boolean(mine),
1203
+ tools: all.map((t) => {
1204
+ const chosen = mine?.find((m) => m.remote === t.remote)
1205
+ return { remote: t.remote, name: t.name, description: t.description, ...(t.readOnly !== undefined ? { readOnly: t.readOnly } : {}), ...(t.destructive ? { destructive: true } : {}), default: t.decision, selected: Boolean(chosen), decision: chosen?.decision ?? t.decision }
1206
+ }),
1207
+ }
1208
+ }
1209
+ if (req.method === 'GET') return json(200, await view())
1210
+ if (req.method !== 'PUT') return json(404, { error: 'not found' })
1211
+ if (!secretsProtected) return json(403, { error: 'Changing an agent\'s tools needs LOOPENGINE_ADMIN_AUTH set first.' })
1212
+ const body = await readJsonBody(req)
1213
+ const decided = (d: unknown): d is 'allow' | 'ask' | 'deny' => d === 'allow' || d === 'ask' || d === 'deny'
1214
+ const choice = (Array.isArray(body.tools) ? body.tools : []).flatMap((t: unknown) => {
1215
+ const e = (t ?? {}) as { remote?: unknown; decision?: unknown }
1216
+ return typeof e.remote === 'string' && decided(e.decision) ? [{ remote: e.remote, decision: e.decision }] : []
1217
+ })
1218
+ if (!choice.length) return json(400, { error: 'Choose at least one tool, or remove the app from the agent instead.' })
1219
+ if (agentAppTools(ability)[agent]) configureAgentMcpTools(agent, ability, dir, choice)
1220
+ else await installAbility(agent, app.spec, { mcpTools: choice.map((c) => c.remote), mcpDecisions: Object.fromEntries(choice.map((c) => [c.remote, c.decision])) })
1221
+ return json(200, await view())
1222
+ } catch (err) {
1223
+ const message = err instanceof Error ? err.message : String(err)
1224
+ if (err instanceof AbilityManifestError || err instanceof AbilityCollisionError || err instanceof AppToolsError) return json(400, { error: message })
1225
+ console.error('[loopengine] agent app failed:', err)
1226
+ return json(502, { error: message })
1227
+ }
1228
+ }
1229
+
1230
+ async function handleApps(req: IncomingMessage, res: ServerResponse, pathname: string): Promise<void> {
1231
+ const json = (status: number, body: unknown): void => {
1232
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
1233
+ }
1234
+ try {
1235
+ if (pathname === '/config/apps' && req.method === 'GET') {
1236
+ // Icons arrive from the registry on a first load; wait briefly.
1237
+ await Promise.race([loadAppIcons(), new Promise((resolve) => setTimeout(resolve, 2500))])
1238
+ return json(200, describeApps())
1239
+ }
1240
+ if (pathname === '/config/apps/search' && req.method === 'GET') {
1241
+ const q = new URL(req.url ?? '/', 'http://localhost').searchParams.get('q')?.trim() ?? ''
1242
+ if (q.length < 2) return json(400, { error: 'Type at least two letters.' })
1243
+ return json(200, { servers: await searchMcpServers(q, 15) })
1244
+ }
1245
+ if (pathname === '/config/apps/connect' && req.method === 'POST') {
1246
+ if (!secretsProtected) return json(403, { error: 'Connecting an app saves its key on this server, so it needs LOOPENGINE_ADMIN_AUTH set first.' })
1247
+ const body = await readJsonBody(req)
1248
+ const id = typeof body.id === 'string' ? body.id.trim() : ''
1249
+ const key = typeof body.key === 'string' ? body.key.trim() : ''
1250
+ const server = id ? await appServer(id) : undefined
1251
+ if (!server) return json(400, { error: 'No such app.' })
1252
+ let picked: ReturnType<typeof pickInterviewModel>
1253
+ try {
1254
+ picked = pickInterviewModel(interviewAgents())
1255
+ } catch {
1256
+ picked = undefined
1257
+ }
1258
+ const generated = await generateMcpAbility({
1259
+ server,
1260
+ ...(key ? { key } : {}),
1261
+ workflow: { name: `${server.title} for this workspace`, summary: `Everyday work with ${server.title}`, steps: [] },
1262
+ ...(picked ? { modelCall: picked.modelCall } : {}),
1263
+ })
1264
+ try {
1265
+ addWorkspaceAbility(generated.dir, `mcp:${server.url}`)
1266
+ } finally {
1267
+ rmSync(generated.dir, { recursive: true, force: true })
1268
+ }
1269
+ if (key && generated.keyEnv) await setEnvVar(generated.keyEnv, key)
1270
+ return json(200, { ...describeApps(), connectedAbility: generated.name })
1271
+ }
1272
+ // A connected app's tools: everything its server offers, each with
1273
+ // its default permission and the agents that use it. Defaults only
1274
+ // affect agents that add a tool from now on.
1275
+ if (pathname === '/config/apps/tools' || pathname === '/config/apps/refresh') {
1276
+ const params = new URL(req.url ?? '/', 'http://localhost').searchParams
1277
+ const body = req.method === 'POST' ? await readJsonBody(req) : {}
1278
+ const ability = String(req.method === 'POST' ? body.ability ?? '' : params.get('ability') ?? '')
1279
+ const app = listWorkspaceAbilities().find((a) => a.name === ability && isAppAbility(a))
1280
+ if (!app) return json(400, { error: 'That app isn\'t connected.' })
1281
+ const envValue = (name: string) => process.env[name]
1282
+ const list = async (fresh = false) => appTools(app.spec, envValue, agentAppTools(app.name), { fresh })
1283
+ if (req.method === 'GET') return json(200, { tools: await list(params.get('fresh') === '1') })
1284
+ if (req.method !== 'POST') return json(404, { error: 'not found' })
1285
+ if (!secretsProtected) return json(403, { error: 'Changing an app needs LOOPENGINE_ADMIN_AUTH set first.' })
1286
+ if (pathname === '/config/apps/refresh') {
1287
+ const changed = await refreshAppTools(app.spec, envValue)
1288
+ return json(200, { tools: await list(), ...changed, ...describeApps() })
1289
+ }
1290
+ const tool = typeof body.tool === 'string' ? body.tool : ''
1291
+ const decision = body.decision === 'allow' || body.decision === 'ask' || body.decision === 'deny' ? body.decision : undefined
1292
+ if (!tool || !decision) return json(400, { error: 'tool and decision are required' })
1293
+ setAppDefault(app.spec, tool, decision)
1294
+ return json(200, { tools: await list() })
1295
+ }
1296
+ if (pathname === '/config/apps/connect' && req.method === 'DELETE') {
1297
+ if (!secretsProtected) return json(403, { error: 'Disconnecting an app needs LOOPENGINE_ADMIN_AUTH set first.' })
1298
+ const ability = new URL(req.url ?? '/', 'http://localhost').searchParams.get('ability') ?? ''
1299
+ const found = listWorkspaceAbilities().find((a) => a.name === ability && isAppAbility(a))
1300
+ if (!found) return json(400, { error: 'That app isn\'t connected.' })
1301
+ removeWorkspaceAbility(found.name)
1302
+ return json(200, describeApps())
1303
+ }
1304
+ return json(404, { error: 'not found' })
1305
+ } catch (err) {
1306
+ const message = err instanceof Error ? err.message : String(err)
1307
+ if (err instanceof McpAbilityGenerationError || err instanceof WorkspaceAbilityError || err instanceof AppToolsError) return json(400, { error: message })
1308
+ if (isMcpAuthError(err)) return json(400, { error: 'The app turned down its saved key; reconnect it with a new one.' })
1309
+ console.error('[loopengine] apps failed:', err)
1310
+ return json(502, { error: message })
1311
+ }
1312
+ }
1313
+
1314
+ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathname: string): Promise<void> {
1315
+ const json = (status: number, body: unknown): void => {
1316
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
1317
+ }
1318
+ const [action, jobId] = pathname.split('/').slice(2) // after /workflows
1319
+ const dirOf = (name: string) => join(projectDir(), 'agents', name)
1320
+ try {
1321
+ // No model to plan with until an agent names one and its key is set
1322
+ // (building the model call throws without the key).
1323
+ let picked: ReturnType<typeof pickInterviewModel>
1324
+ try {
1325
+ picked = pickInterviewModel(interviewAgents())
1326
+ } catch {
1327
+ picked = undefined
1328
+ }
1329
+ if (!action && req.method === 'GET') return json(200, { ready: Boolean(picked), canInstallAbilities: secretsProtected, agents: listAgents() })
1330
+ // A planning job's progress (the page checks every few seconds).
1331
+ if (action === 'plan' && jobId && req.method === 'GET') {
1332
+ const job = getPlanJob(jobId)
1333
+ return job ? json(200, job) : json(404, { error: 'No such planning job (it may have expired); plan it again.' })
1334
+ }
1335
+ if (action === 'plan' && !jobId && req.method === 'POST') {
1336
+ if (!picked) return json(409, { error: 'No model is ready to plan with: set one on an agent (Overview → Model) and add its key under Models.' })
1337
+ const body = await readJsonBody(req)
1338
+ const base = interviewTools(interviewAgents())
1339
+ // The same model, with room for a whole plan in one reply (an
1340
+ // agent's own model call stops at its maxTokens, often 4096).
1341
+ let modelCall = picked.modelCall
1342
+ try {
1343
+ modelCall = (await synthesizeCreateModelCall({ ...picked.model, maxTokens: 8192 } as AgentModelConfig))()
1344
+ } catch {}
1345
+ if (typeof body.description !== 'string' || body.description.trim().length < 15) {
1346
+ return json(400, { error: 'Describe the workflow in a sentence or two: what starts it, the steps, and where you want to decide.' })
1347
+ }
1348
+ const job = startPlanJob(() => planWorkflow(body.description, modelCall, {
1349
+ agentInfo: workflowAgentInfo,
1350
+ // A summary: an agent's first tools and its rules that aren't the
1351
+ // default, to keep what the model reads short.
1352
+ listAgents: async () => (await workflowAgentInfo()).map((a) => ({
1353
+ name: a.name,
1354
+ abilities: a.abilities,
1355
+ messaging: a.messaging,
1356
+ defaultDecision: a.defaultDecision,
1357
+ toolCount: a.tools.length,
1358
+ tools: a.tools.slice(0, 40),
1359
+ rules: a.rules.filter((r) => r.decision !== a.defaultDecision).slice(0, 40).map((r) => `${r.tool}: ${r.decision}`),
1360
+ })),
1361
+ searchPublicAbilities: (query) => base.searchPublicAbilities(query),
1362
+ listWorkspaceAbilities: async () => listWorkspaceAbilities().map((a) => ({ name: a.name, description: a.description, spec: a.spec })),
1363
+ abilityDetails: async (spec) => {
1364
+ const d = await abilityDetails(spec)
1365
+ return { name: d.name, tools: d.tools.map((t) => ({ name: t.name, description: t.description.slice(0, 300), decision: t.decision ?? null })), settings: d.settings.map((x) => x.name) }
1366
+ },
1367
+ listConnections: () => base.listConnections(),
1368
+ searchMcpServers: async (query) => (await searchMcpServers(query)).map((s) => ({ id: s.id, title: s.title, description: s.description, url: s.url, official: Boolean(s.official), verified: s.verified, source: s.source })),
1369
+ mcpServerTools: workflowMcpServerTools,
1370
+ mcp: workflowMcp,
1371
+ }))
1372
+ return json(202, job)
1373
+ }
1374
+ if (action === 'create' && req.method === 'POST') {
1375
+ const body = await readJsonBody(req)
1376
+ const model = pickAgentModel(interviewAgents())
1377
+ const created = await createWorkflow(body, {
1378
+ addAppTools: async (agent, spec, tools) => addAgentAppTools(agent, spec, tools),
1379
+ mcp: {
1380
+ ...workflowMcp,
1381
+ // The planning model picks the server's tools and writes the skill.
1382
+ generate: (request) => generateMcpAbility({ ...request, ...(picked ? { modelCall: picked.modelCall } : {}) }),
1383
+ store: (dir, source) => {
1384
+ try {
1385
+ return addWorkspaceAbility(dir, source).spec
1386
+ } finally {
1387
+ rmSync(dir, { recursive: true, force: true })
1388
+ }
1389
+ },
1390
+ saveKey: async (name, value) => {
1391
+ if (!secretsProtected) throw new Error('saving keys needs LOOPENGINE_ADMIN_AUTH set on the server')
1392
+ await setEnvVar(name, value)
1393
+ },
1394
+ },
1395
+ agentExists: (name) => Boolean(getEntry(name)) || existsSync(dirOf(name)),
1396
+ agentDir: dirOf,
1397
+ agentInfo: workflowAgentInfo,
1398
+ scaffoldAgent: async (name, systemPrompt) => {
1399
+ const parsed = parseAgentTemplateOptions({ systemPrompt, ...(model ? { model } : {}) })
1400
+ if (!parsed.ok) throw new WorkflowError(parsed.error)
1401
+ await scaffoldAgent(projectDir(), name, parsed.value)
1402
+ },
1403
+ registerAgent: async (name) => {
1404
+ const indexPath = join(dirOf(name), 'index.ts')
1405
+ registerAgent(await loadAgentModule(indexPath, indexPath))
1406
+ },
1407
+ // Skills, rules and gateway tools are read on every run, so a
1408
+ // loaded agent already sees them; new ability code restarts the
1409
+ // server (exitIfCodeChanged). Only an agent not loaded yet is.
1410
+ reloadAgent: async (name) => {
1411
+ if (getEntry(name)) return
1412
+ const indexPath = join(dirOf(name), 'index.ts')
1413
+ registerAgent(await loadAgentModule(indexPath, indexPath))
1414
+ },
1415
+ // As for templates and the Apps tab: installing runs
1416
+ // third-party code, so only behind the admin login.
1417
+ ...(secretsProtected ? { installAbility: async (name: string, spec: string, options?: { mcpTools?: string[] }) => void (await installAbility(name, spec, options ?? {})) } : {}),
1418
+ prepareApp: prepareWorkspaceApp,
1419
+ envValue: (name, variable) => createAgentEnv(name, dirOf(name)).get(variable),
1420
+ messagingConfigured: (name) => {
1421
+ const entry = getEntry(name)
1422
+ return entry ? describeAgentNotifier(entry.config, defaultWebhook).source !== 'none' : false
1423
+ },
1424
+ })
1425
+ json(200, created)
1426
+ // New tools in an agent that was already running load with a restart,
1427
+ // as after installing an ability from its tab.
1428
+ if (!created.newAgent && created.abilities.installed.length) exitIfCodeChanged(res)
1429
+ return
1430
+ }
1431
+ return json(404, { error: 'not found' })
1432
+ } catch (err) {
1433
+ const message = err instanceof Error ? err.message : String(err)
1434
+ if (err instanceof WorkflowError) return json(400, { error: message })
1435
+ console.error('[loopengine] workflow failed:', err)
1436
+ return json(502, { error: `Couldn't plan or create the workflow: ${message}` })
1437
+ }
1438
+ }
1439
+
1440
+ // An agent's workflows (its Overview): list, run now, forget.
1441
+ async function handleAgentWorkflows(req: IncomingMessage, res: ServerResponse, agentName: string, id: string | undefined, action: string | undefined): Promise<void> {
1442
+ const json = (status: number, body: unknown): void => {
1443
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
1444
+ }
1445
+ if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
1446
+ const dir = agentDir(agentName)
1447
+ try {
1448
+ if (!id && req.method === 'GET') return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
1449
+ if (id && action === 'run' && req.method === 'POST') return json(200, await runWorkflowNow(agentName, dir, id))
1450
+ if (id && !action && req.method === 'DELETE') {
1451
+ forgetWorkflow(dir, id)
1452
+ return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
1453
+ }
1454
+ return json(404, { error: 'not found' })
1455
+ } catch (err) {
1456
+ const message = err instanceof Error ? err.message : String(err)
1457
+ return json(err instanceof WorkflowError ? 400 : 500, { error: message })
1458
+ }
1459
+ }
1460
+
1017
1461
  // Templates (loopengine's web/templates-admin.ts): ready-made agents,
1018
1462
  // created through the same scaffold and registration as Agents → + New.
1019
1463
  // A template agent takes the model of an agent that already works here,
@@ -1045,10 +1489,11 @@ async function handleStarterTemplates(req: IncomingMessage, res: ServerResponse,
1045
1489
  const indexPath = join(dirOf(name), 'index.ts')
1046
1490
  registerAgent(await loadAgentModule(indexPath, indexPath))
1047
1491
  },
1048
- // Same rule as the Abilities tab: installing runs third-party code,
1492
+ // Same rule as the Apps tab: installing runs third-party code,
1049
1493
  // so only behind the admin login. The agent isn't loaded yet, so
1050
1494
  // nothing needs a restart.
1051
- ...(secretsProtected ? { installAbility: async (name: string, spec: string) => void (await installAbility(name, spec)) } : {}),
1495
+ ...(secretsProtected ? { installAbility: async (name: string, spec: string, options?: { mcpTools?: string[] }) => void (await installAbility(name, spec, options ?? {})) } : {}),
1496
+ prepareApp: prepareWorkspaceApp,
1052
1497
  envValue,
1053
1498
  messagingConfigured: (name) => {
1054
1499
  const entry = getEntry(name)
@@ -1642,7 +2087,7 @@ function hasNodeModule(name: string): boolean {
1642
2087
  return existsSync(join(process.cwd(), 'node_modules', name))
1643
2088
  }
1644
2089
 
1645
- // Backs the Admin UI's Abilities tab list — everything currently
2090
+ // Backs the Admin UI's Apps tab list — everything currently
1646
2091
  // installed for this agent, each annotated with the latest version
1647
2092
  // currently published on the public npm registry (fetchLatestAbilityVersion,
1648
2093
  // best-effort — null for a private/file:/git-installed ability, or on any
@@ -1682,7 +2127,7 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
1682
2127
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities }))
1683
2128
  }
1684
2129
 
1685
- // Backs the Abilities tab's search box — proxies to npm's own public
2130
+ // Backs the Apps tab's search box — proxies to npm's own public
1686
2131
  // registry search (loopengine's own searchPublicAbilities) so the
1687
2132
  // browser never needs direct access to registry.npmjs.org, and the
1688
2133
  // actual query text (the loopengine-ability keyword filter) stays a
@@ -3488,6 +3933,27 @@ const server = createServer(async (req, res) => {
3488
3933
  await handleInterviews(req, res, pathname)
3489
3934
  return
3490
3935
  }
3936
+ // "Describe a workflow": the page, and its routes.
3937
+ if (req.method === 'GET' && pathname === '/workflows/new') {
3938
+ res.writeHead(200, { 'content-type': 'text/html' }).end(workflowPageHtml)
3939
+ return
3940
+ }
3941
+ if (pathname === '/workflows' || pathname === '/workflows/plan' || pathname.startsWith('/workflows/plan/') || pathname === '/workflows/create') {
3942
+ await handleWorkflows(req, res, pathname)
3943
+ return
3944
+ }
3945
+ // An agent's tools from one connected app: which it has, and each
3946
+ // one's permission for this agent (the Apps tab's app picker).
3947
+ const agentAppMatch = pathname.match(/^\/agents\/([^/]+)\/apps\/([^/]+)$/)
3948
+ if (agentAppMatch) {
3949
+ await handleAgentApp(req, res, decodeURIComponent(agentAppMatch[1]), decodeURIComponent(agentAppMatch[2]))
3950
+ return
3951
+ }
3952
+ const agentWorkflowsMatch = pathname.match(/^\/agents\/([^/]+)\/workflows(?:\/([a-z0-9-]+))?(?:\/(run))?$/)
3953
+ if (agentWorkflowsMatch) {
3954
+ await handleAgentWorkflows(req, res, decodeURIComponent(agentWorkflowsMatch[1]), agentWorkflowsMatch[2], agentWorkflowsMatch[3])
3955
+ return
3956
+ }
3491
3957
  // Templates: the page, and its routes.
3492
3958
  if (req.method === 'GET' && pathname === '/templates') {
3493
3959
  res.writeHead(200, { 'content-type': 'text/html' }).end(templatesPageHtml)
@@ -3848,7 +4314,7 @@ const server = createServer(async (req, res) => {
3848
4314
  return
3849
4315
  }
3850
4316
 
3851
- // Backs the Admin UI's Abilities tab — see handleAbilitiesGet/
4317
+ // Backs the Admin UI's Apps tab — see handleAbilitiesGet/
3852
4318
  // handleAbilitiesSearchGet/handleAbilityInstallPost's own doc
3853
4319
  // comments above. The /search route is checked before the plain
3854
4320
  // :agentName routes right below since 'search' would otherwise
@@ -3898,10 +4364,17 @@ const server = createServer(async (req, res) => {
3898
4364
  }
3899
4365
  return
3900
4366
  }
4367
+ // The workspace's Apps (core/mcp-apps.ts): popular apps' MCP servers,
4368
+ // and the ones connected. Connecting stores a key and makes a
4369
+ // workspace ability, so like installing one it needs the admin login.
4370
+ if (pathname.startsWith('/config/apps')) {
4371
+ await handleApps(req, res, pathname)
4372
+ return
4373
+ }
3901
4374
  // The workspace's private abilities (core/workspace-abilities.ts):
3902
4375
  // installed from the Abilities page, added to agents from their tab.
3903
4376
  if (pathname === '/abilities/workspace' && req.method === 'GET') {
3904
- res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
4377
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ abilities: listWorkspaceAbilities().map((a) => ({ ...a, app: isAppAbility(a) })) }))
3905
4378
  return
3906
4379
  }
3907
4380
  if (pathname === '/abilities/workspace' && (req.method === 'POST' || req.method === 'DELETE')) {
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.96",
12
+ "loopengine": "^0.2.0",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"