loopengine 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/core/agent-config.d.ts +2 -0
  2. package/dist/core/http-notifier.js +14 -3
  3. package/dist/core/http-notify-triggers/telegram.d.ts +20 -1
  4. package/dist/core/http-notify-triggers/telegram.js +63 -17
  5. package/dist/core/messaging/connector.d.ts +7 -0
  6. package/dist/core/messaging/connector.js +1 -1
  7. package/dist/core/messaging/connectors/slack/events.d.ts +2 -1
  8. package/dist/core/messaging/connectors/slack/events.js +6 -2
  9. package/dist/core/messaging/connectors/slack/setup.d.ts +2 -1
  10. package/dist/core/messaging/connectors/slack/setup.js +10 -5
  11. package/dist/core/messaging/connectors/telegram/format.d.ts +6 -0
  12. package/dist/core/messaging/connectors/telegram/format.js +57 -0
  13. package/dist/core/messaging/connectors/telegram/index.d.ts +39 -0
  14. package/dist/core/messaging/connectors/telegram/index.js +136 -0
  15. package/dist/core/messaging/connectors/telegram/store.d.ts +31 -0
  16. package/dist/core/messaging/connectors/telegram/store.js +69 -0
  17. package/dist/core/messaging/connectors/telegram/updates.d.ts +40 -0
  18. package/dist/core/messaging/connectors/telegram/updates.js +112 -0
  19. package/dist/core/messaging/jobs.d.ts +44 -0
  20. package/dist/core/messaging/jobs.js +100 -0
  21. package/dist/core/messaging/threads.d.ts +12 -0
  22. package/dist/core/messaging/threads.js +36 -0
  23. package/dist/core/notifier-file.d.ts +13 -5
  24. package/dist/core/notifier-file.js +41 -6
  25. package/dist/core/run-callbacks.d.ts +39 -0
  26. package/dist/core/run-callbacks.js +125 -0
  27. package/dist/core/system-agents/workflow-planner.d.ts +37 -4
  28. package/dist/core/system-agents/workflow-planner.js +79 -5
  29. package/dist/core/workflow-runs.d.ts +131 -0
  30. package/dist/core/workflow-runs.js +229 -0
  31. package/dist/core/workflow-triggers.d.ts +140 -0
  32. package/dist/core/workflow-triggers.js +378 -0
  33. package/dist/web/agents-config-page.js +168 -50
  34. package/dist/web/notifier-admin.d.ts +69 -13
  35. package/dist/web/notifier-admin.js +127 -31
  36. package/dist/web/workflow-admin.d.ts +10 -0
  37. package/dist/web/workflow-admin.js +42 -3
  38. package/dist/web/workflow-page.js +93 -2
  39. package/package.json +7 -1
@@ -1,3 +1,11 @@
1
+ // Backs the console's Messaging tab: where an agent's durable
2
+ // approvals/questions go (core/http-notifier.ts's describeNotifier plus
3
+ // the deployment-wide default webhook), whether it answers replies there,
4
+ // editing its agents/<name>/messaging.yml, a test send and recent
5
+ // deliveries — and the decisions still waiting, shown in Permissions. Plain functions over loopengine's own modules
6
+ // so adapters/http.ts (and the scaffolded copy create-loopengine ships)
7
+ // only needs thin route handlers.
8
+ import { randomBytes } from 'node:crypto';
1
9
  import { agentDirFor, createAgentEnv } from '../core/agent-env.js';
2
10
  import { decidePath } from '../core/approval-links.js';
3
11
  import { describeNotifier } from '../core/http-notifier.js';
@@ -7,8 +15,9 @@ import { SlackNotifier } from '../core/http-notify-triggers/slack.js';
7
15
  import { LarkNotifier } from '../core/http-notify-triggers/lark.js';
8
16
  import { TelegramNotifier, sendTestTelegramMessage } from '../core/http-notify-triggers/telegram.js';
9
17
  import { listDeliveries } from '../core/notifier-deliveries.js';
10
- import { NOTIFIER_CHANNELS, NOTIFIER_SECRET_VARS, PRIMARY_CHANNELS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, NotifierFileError, notifierConfigFromFile, readActiveConnections, readMessagingConnections, readNotifierFile, removeMessagingConnection, removeNotifierFile, setPrimaryConnection, writeNotifierFile } from '../core/notifier-file.js';
18
+ import { NOTIFIER_CHANNELS, NOTIFIER_SECRET_VARS, PRIMARY_CHANNELS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR, NotifierFileError, notifierConfigFromFile, readActiveConnections, readMessagingConnections, readNotifierFile, removeMessagingConnection, removeNotifierFile, setPrimaryConnection, writeNotifierFile } from '../core/notifier-file.js';
11
19
  import { listSlackChannelMembers, listSlackChannels, slackAppManifest } from '../core/messaging/connectors/slack/index.js';
20
+ import { telegramBotUsername, telegramPeople, setTelegramWebhook, telegramWebhookInfo } from '../core/messaging/connectors/telegram/index.js';
12
21
  import { setAgentEnvVar } from './env-admin.js';
13
22
  export function describeAgentNotifier(config, defaultWebhook) {
14
23
  const described = describeNotifier(config);
@@ -49,6 +58,11 @@ export function describeAgentNotifier(config, defaultWebhook) {
49
58
  // interactivityPath as you type.
50
59
  manifestTemplate: slackAppManifest({ agentName: config.name, interactivityUrl: '__INTERACTIVITY_URL__' }),
51
60
  },
61
+ telegram: {
62
+ // Set once the bot's webhook points here (setupAgentTelegramWebhook).
63
+ webhookSet: env.get(NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR) !== undefined,
64
+ webhookPath: `/hooks/telegram/${encodeURIComponent(config.name)}`,
65
+ },
52
66
  deliveries: listDeliveries(config.name),
53
67
  };
54
68
  }
@@ -89,49 +103,131 @@ export function slackCredentialsFor(config) {
89
103
  const botToken = config.httpNotifier?.channel === 'slack' ? config.httpNotifier.config.botToken : env.get(NOTIFIER_SECRET_VARS.slack);
90
104
  return { botToken, signingSecret: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) };
91
105
  }
92
- /** Whether replies in a Slack request's thread are answered: on unless
93
- * the Messaging tab turned Conversations off. An agent whose Slack
94
- * notifier is set in code always answers. */
106
+ const APP_NAMES = { slack: 'Slack', telegram: 'Telegram' };
107
+ /** Whether replies in an app's request threads are answered: on unless
108
+ * that app's section of the Messaging tab turned Conversations off. An
109
+ * agent whose notifier is set in code answers in that app only. */
110
+ export function conversationsOn(config, app) {
111
+ if (config.httpNotifier)
112
+ return config.httpNotifier.channel === app;
113
+ const file = savedConnection(config, app);
114
+ return Boolean(file) && file.conversations !== false;
115
+ }
95
116
  export function slackConversationsOn(config) {
117
+ return conversationsOn(config, 'slack');
118
+ }
119
+ /** Whether a DM to the agent's bot in `app`, or a mention of it, starts a
120
+ * job (core/messaging/jobs.ts). Off unless turned on in that app's
121
+ * section; never for a notifier set in code. */
122
+ export function jobsOn(config, app) {
96
123
  if (config.httpNotifier)
97
- return config.httpNotifier.channel === 'slack';
98
- try {
99
- return readNotifierFile(agentDirFor(config))?.conversations !== false;
100
- }
101
- catch {
102
124
  return false;
103
- }
125
+ return savedConnection(config, app)?.jobs === true;
104
126
  }
105
- /** The people who can decide this agent's requests from Slack: from
106
- * messaging.yml; none when the Slack notifier is set in code. */
107
- export function agentApprovers(config) {
127
+ /** The people who can decide this agent's requests from `app`: from that
128
+ * app's connection in messaging.yml; none when the notifier is set in
129
+ * code. */
130
+ export function agentApprovers(config, app = 'slack') {
108
131
  if (config.httpNotifier)
109
132
  return [];
110
- try {
111
- return readNotifierFile(agentDirFor(config))?.approvers ?? [];
112
- }
113
- catch {
114
- return [];
115
- }
133
+ return savedConnection(config, app)?.approvers ?? [];
116
134
  }
117
- /** Whether a Slack user's button click counts: anyone's does while the
118
- * agent has no approvers (as before approvers existed), only theirs once
119
- * it has some. `message` says who can, for a refused click. */
120
- export function slackClickAllowed(config, user) {
121
- const approvers = agentApprovers(config);
135
+ function approverList(app, approvers) {
136
+ return approvers.map((a) => (app === 'slack' ? `<@${a.user}>` : a.name ?? a.user)).join(', ');
137
+ }
138
+ /** Whether a person's button click counts: anyone's does while the
139
+ * agent has no approvers in that app (as before approvers existed), only
140
+ * theirs once it has some. `message` says who can, for a refused click. */
141
+ export function clickAllowed(config, app, user) {
142
+ const approvers = agentApprovers(config, app);
122
143
  if (!approvers.length || (user && approvers.some((a) => a.user === user)))
123
144
  return { allowed: true };
124
- return { allowed: false, message: `Only approvers can decide this: ${approvers.map((a) => `<@${a.user}>`).join(', ')}.` };
145
+ return { allowed: false, message: `Only approvers can decide this: ${approverList(app, approvers)}.` };
125
146
  }
126
- /** Replaces the approver list on the agent's saved Slack connection,
127
- * primary or not. Throws NotifierFileError when there's no Slack
128
- * connection to add them to. */
129
- export function setAgentApprovers(config, approvers) {
130
- const file = savedConnection(config, 'slack');
147
+ export function slackClickAllowed(config, user) {
148
+ return clickAllowed(config, 'slack', user);
149
+ }
150
+ /** Whether a person may start a job from a message, or continue one: an
151
+ * approver, once the app has any; with none, anyone in the agent's own
152
+ * channel or chat, but nobody by DM. */
153
+ export function jobAllowed(config, app, message) {
154
+ const approvers = agentApprovers(config, app);
155
+ if (approvers.length) {
156
+ if (approvers.some((a) => a.user === message.user))
157
+ return { allowed: true };
158
+ return { allowed: false, message: `Only approvers can give me jobs here: ${approverList(app, approvers)}.` };
159
+ }
160
+ const file = savedConnection(config, app);
161
+ const home = app === 'slack' ? file?.channelId : file?.chatId;
162
+ if (!message.direct && home && home === message.conversation)
163
+ return { allowed: true };
164
+ return { allowed: false, message: `Jobs by direct message need approvers: add yourself in this agent's Messaging tab in LoopEngine (${APP_NAMES[app]} section).` };
165
+ }
166
+ /** Replaces the approver list on the agent's saved connection for `app`,
167
+ * primary or not. Throws NotifierFileError when there's no connection
168
+ * for that app to add them to. */
169
+ export function setAgentApprovers(config, approvers, app = 'slack') {
170
+ const file = savedConnection(config, app);
131
171
  if (!file)
132
- throw new NotifierFileError('Save a Slack connection first');
172
+ throw new NotifierFileError(`Save a ${APP_NAMES[app]} connection first`);
133
173
  return writeNotifierFile(agentDirFor(config), { ...file, approvers: Array.isArray(approvers) ? approvers : [] }).approvers ?? [];
134
174
  }
175
+ /** The bot token behind an agent's Telegram notifier and the secret its
176
+ * webhook was set up with, like slackCredentialsFor. */
177
+ export function telegramCredentialsFor(config) {
178
+ const env = createAgentEnv(config.name, agentDirFor(config));
179
+ const botToken = config.httpNotifier?.channel === 'telegram' ? config.httpNotifier.config.botToken : env.get(NOTIFIER_SECRET_VARS.telegram);
180
+ return { botToken, webhookSecret: env.get(NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR) };
181
+ }
182
+ /** The address remembered for links when the app's connection was saved. */
183
+ export function connectionPublicUrl(config, app) {
184
+ return savedConnection(config, app)?.publicUrl;
185
+ }
186
+ /** The Telegram chat the agent posts requests in, if any. */
187
+ export function telegramHomeChat(config) {
188
+ return config.httpNotifier?.channel === 'telegram' ? config.httpNotifier.config.chatId : savedConnection(config, 'telegram')?.chatId;
189
+ }
190
+ /** People who have written to the agent's Telegram bot, newest first,
191
+ * for picking approvers: the Bot API can't list a chat's members. */
192
+ export function listAgentTelegramPeople(config) {
193
+ return telegramPeople(agentDirFor(config));
194
+ }
195
+ /** Points the agent's Telegram bot at POST /hooks/telegram/<agent> on
196
+ * `baseUrl` (LOOPENGINE_PUBLIC_URL wins), with a secret made once and
197
+ * kept in the agent's env. From then on requests get buttons and replies
198
+ * reach the agent. */
199
+ export async function setupAgentTelegramWebhook(config, baseUrl) {
200
+ const { botToken, webhookSecret } = telegramCredentialsFor(config);
201
+ if (!botToken)
202
+ throw new Error('Save the Telegram bot token first');
203
+ const base = (process.env.LOOPENGINE_PUBLIC_URL || baseUrl).replace(/\/+$/, '');
204
+ if (!/^https:\/\//.test(base))
205
+ throw new Error('Telegram only sends updates to an https address; set LOOPENGINE_PUBLIC_URL to this server\'s https address');
206
+ const secret = webhookSecret ?? randomBytes(24).toString('hex');
207
+ const url = `${base}/hooks/telegram/${encodeURIComponent(config.name)}`;
208
+ const set = await setTelegramWebhook(botToken, url, secret);
209
+ if (!set.ok)
210
+ throw new Error(set.error);
211
+ if (!webhookSecret)
212
+ await setAgentEnvVar(config.name, NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR, secret);
213
+ return { url, ...(set.botUsername ? { botUsername: set.botUsername } : {}) };
214
+ }
215
+ /** Where the agent's Telegram bot sends its updates now. */
216
+ export async function agentTelegramWebhookInfo(config) {
217
+ const { botToken } = telegramCredentialsFor(config);
218
+ if (!botToken)
219
+ throw new Error('Save the Telegram bot token first');
220
+ const info = await telegramWebhookInfo(botToken);
221
+ if (!info.ok)
222
+ throw new Error(info.error);
223
+ const botUsername = await telegramBotUsername(botToken);
224
+ return {
225
+ ...(info.url ? { url: info.url } : {}),
226
+ ...(info.lastError ? { lastError: info.lastError } : {}),
227
+ pointsHere: Boolean(info.url?.endsWith(`/hooks/telegram/${encodeURIComponent(config.name)}`)),
228
+ ...(botUsername ? { botUsername } : {}),
229
+ };
230
+ }
135
231
  /** The people in the agent's Slack channel, for picking approvers. */
136
232
  export async function listAgentSlackMembers(config) {
137
233
  const { botToken } = slackCredentialsFor(config);
@@ -1,5 +1,6 @@
1
1
  import { type CreatedTemplateAgent, type TemplateChecklistItem, type TemplateCreateDeps } from './templates-admin.js';
2
2
  import { type WorkflowPlan, type WorkflowPlannerTools } from '../core/system-agents/workflow-planner.js';
3
+ import { type PublicTrigger } from '../core/workflow-triggers.js';
3
4
  import { type GeneratedMcpAbility, type McpAbilityRequest } from '../core/mcp-ability-generator.js';
4
5
  import type { McpDirectoryServer } from '../core/mcp-directory.js';
5
6
  import { type Workflow } from '../core/workflows.js';
@@ -126,6 +127,15 @@ export interface CreatedWorkflow {
126
127
  checklist: TemplateChecklistItem[];
127
128
  schedule?: CreatedTemplateAgent['schedule'];
128
129
  scheduleError?: string;
130
+ /** The plan's webhook, made turned off until its signing secret is
131
+ * added. `token` makes its URL, shown once (the server turns it into
132
+ * `url`). */
133
+ webhook?: {
134
+ trigger: PublicTrigger;
135
+ token: string;
136
+ setup?: string;
137
+ };
138
+ webhookError?: string;
129
139
  loadError?: string;
130
140
  }
131
141
  export interface WorkflowCreateDeps extends TemplateCreateDeps {
@@ -10,7 +10,8 @@ import { addActauthRule, readActauthConfig } from './actauth-admin.js';
10
10
  import { writeSkill } from './skills-admin.js';
11
11
  import { createFromTemplateObject, installFailureReason, templateChecklist } from './templates-admin.js';
12
12
  import { templateScheduleStart } from '../core/system-agents/templates.js';
13
- import { planParameters, planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
13
+ import { planParameters, planWebhook, planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
14
+ import { createTrigger, publicTrigger } from '../core/workflow-triggers.js';
14
15
  import { mcpAbilityNames } from '../core/mcp-ability-generator.js';
15
16
  import { clearWorkflowFields, migrateWorkflowsFile, readWorkflow, readWorkflows, setWorkflowFields, validateParams, workflowRunMessage } from '../core/workflows.js';
16
17
  import { isUnderWay, listRuns } from '../core/workflow-runs.js';
@@ -291,6 +292,38 @@ export function getPlanJob(id) {
291
292
  const { at: _at, ...view } = job;
292
293
  return view;
293
294
  }
295
+ const SENDER_NAMES = { stripe: 'Stripe', github: 'GitHub', shopify: 'Shopify', standard: 'the sender', custom: 'your app' };
296
+ /** Makes the plan's webhook for the created workflow: turned off, since
297
+ * the sender's signing secret comes from the sender, after the URL is
298
+ * pasted there. */
299
+ async function makePlannedWebhook(plan, agent, agentDir) {
300
+ const workflow = readWorkflow(agentDir, plan.id);
301
+ const webhook = workflow && planWebhook(plan.webhook, workflow.parameters);
302
+ if (!plan.webhook)
303
+ return { checklist: [] };
304
+ if (!workflow || !webhook)
305
+ return { webhookError: 'The webhook could not be set up from the plan; add it on the workflow’s Webhooks.', checklist: [] };
306
+ try {
307
+ const { trigger, token } = await createTrigger(agent, workflow, {
308
+ name: webhook.name,
309
+ enabled: false,
310
+ verify: { sender: webhook.sender, ...(webhook.header ? { header: webhook.header } : {}) },
311
+ ...(webhook.filter ? { filter: webhook.filter } : {}),
312
+ ...(webhook.mapping ? { mapping: webhook.mapping } : {}),
313
+ ...(plan.notify?.on && plan.trigger.kind === 'webhook' ? { notify: true } : {}),
314
+ });
315
+ return {
316
+ webhook: { trigger: publicTrigger(trigger), token, ...(webhook.setup ? { setup: webhook.setup } : {}) },
317
+ checklist: [{
318
+ label: `Turn on the ${webhook.name} webhook: paste its URL into ${SENDER_NAMES[webhook.sender]}'s webhook settings, then add the signing secret ${webhook.sender === 'custom' ? 'your app signs with' : 'it shows'}`,
319
+ href: `/agents/config?agent=${encodeURIComponent(agent)}&tab=overview`,
320
+ }],
321
+ };
322
+ }
323
+ catch (err) {
324
+ return { webhookError: err instanceof Error ? err.message : String(err), checklist: [] };
325
+ }
326
+ }
294
327
  /** A to-do for each step nothing could be found for, first in the list. */
295
328
  function withMissingSteps(plan, agent, checklist) {
296
329
  const href = `/agents/config?agent=${encodeURIComponent(agent)}&tab=abilities`;
@@ -321,13 +354,16 @@ export async function createWorkflow(input, deps) {
321
354
  const connected = copyConnectedTools(plan, created.name, agents, deps.agentDir);
322
355
  const loadError = created.loadError;
323
356
  markWorkflow(deps.agentDir(created.name));
357
+ const hook = await makePlannedWebhook(plan, created.name, deps.agentDir(created.name));
324
358
  return {
325
359
  agent: created.name,
326
360
  newAgent: true,
327
361
  workflow: plan.name,
328
362
  abilities: { installed: [...created.abilities.installed, ...connected.copied], failed: [...mcp.failed, ...created.abilities.failed, ...connected.failed] },
329
363
  keptDecisions: [],
330
- checklist: withMissingSteps(plan, created.name, created.checklist),
364
+ checklist: [...withMissingSteps(plan, created.name, created.checklist), ...hook.checklist],
365
+ ...(hook.webhook ? { webhook: hook.webhook } : {}),
366
+ ...(hook.webhookError ? { webhookError: hook.webhookError } : {}),
331
367
  ...(created.schedule ? { schedule: created.schedule } : {}),
332
368
  ...(created.scheduleError ? { scheduleError: created.scheduleError } : {}),
333
369
  ...(loadError ? { loadError } : {}),
@@ -433,13 +469,16 @@ export async function createWorkflow(input, deps) {
433
469
  }
434
470
  }
435
471
  markWorkflow(deps.agentDir(agent.name));
472
+ const hook = await makePlannedWebhook(plan, agent.name, deps.agentDir(agent.name));
436
473
  return {
437
474
  agent: agent.name,
438
475
  newAgent: false,
439
476
  workflow: plan.name,
440
477
  abilities: { installed, failed },
441
478
  keptDecisions: check.conflicts,
442
- checklist: withMissingSteps(plan, agent.name, templateChecklist(template, agent.name, deps, failed, appsToConnect)),
479
+ checklist: [...withMissingSteps(plan, agent.name, templateChecklist(template, agent.name, deps, failed, appsToConnect)), ...hook.checklist],
480
+ ...(hook.webhook ? { webhook: hook.webhook } : {}),
481
+ ...(hook.webhookError ? { webhookError: hook.webhookError } : {}),
443
482
  ...(schedule ? { schedule } : {}),
444
483
  ...(scheduleError ? { scheduleError } : {}),
445
484
  ...(loadError ? { loadError } : {}),
@@ -30,6 +30,11 @@ export const workflowPageHtml = `<!doctype html>
30
30
  .wf-target label { display: flex; gap: 8px; align-items: center; font-size: 13.5px; }
31
31
  .wf-notify { display: flex; gap: 8px; align-items: center; font-size: 13.5px; }
32
32
  .wf-notify input { margin: 0; }
33
+ .wf-hook { border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; display: grid; gap: 4px; background: var(--surface); font-size: 13px; }
34
+ .wf-hook ul { margin: 2px 0 0; padding-left: 18px; }
35
+ .wf-hook input[type="text"], .wf-hook input:not([type]) { font: inherit; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); width: 100%; box-sizing: border-box; }
36
+ .wf-hook-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
37
+ .wf-hook-row input { flex: 1; min-width: 220px; }
33
38
  .wf-params { display: grid; gap: 6px; }
34
39
  .wf-param { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 13px; }
35
40
  .wf-param .wf-param-name { width: 160px; font-family: var(--mono, monospace); }
@@ -73,8 +78,10 @@ ${consoleShellScript}
73
78
  var EXAMPLES = [
74
79
  ['Blog post', 'Every Monday morning, browse the web for timely blog post ideas about our product area. Ask me on Slack to pick one of the ideas. Then draft the post and let me review it before it is created.'],
75
80
  ['Weekly digest', 'Every Friday afternoon, search the web for news about our competitors and summarise the important changes. Send me the summary for review before it goes to the team channel.'],
76
- ['Refund requests', 'When asked, look up the customer' + String.fromCharCode(8217) + 's order and check our refund policy. If a refund fits the policy, prepare it and ask me to approve it before it is issued.']
81
+ ['Refund requests', 'When asked, look up the customer' + String.fromCharCode(8217) + 's order and check our refund policy. If a refund fits the policy, prepare it and ask me to approve it before it is issued.'],
82
+ ['Stripe disputes', 'When a Stripe dispute comes in, look up the order and its delivery, write the evidence for Stripe, and ask me before submitting it.']
77
83
  ];
84
+ var SENDERS = { stripe: 'Stripe', github: 'GitHub', shopify: 'Shopify', standard: 'Standard Webhooks', custom: 'your app' };
78
85
  var state = { description: '', planned: null, agents: [], ready: false, canInstall: false };
79
86
 
80
87
  function esc(s) {
@@ -228,6 +235,24 @@ ${consoleShellScript}
228
235
  return { parameters: parameters };
229
236
  }
230
237
 
238
+ // The webhook the plan makes: whose events, which ones, and what each
239
+ // parameter is filled from. Made turned off; its signing secret comes
240
+ // from the sender, after Create.
241
+ function webhookPlanHtml(p) {
242
+ var w = p.webhook;
243
+ if (!w) return '';
244
+ var who = SENDERS[w.sender] || w.sender;
245
+ var lines = [];
246
+ if (w.filter) lines.push('Runs for ' + esc(w.filter.path) + ' ' + esc(w.filter.values.join(' or ')));
247
+ Object.keys(w.mapping || {}).forEach(function (param) { lines.push('<code>' + esc(param) + '</code> ' + String.fromCharCode(8592) + ' <code>' + esc(w.mapping[param]) + '</code> in the payload'); });
248
+ return '<div class="wf-hook">' +
249
+ '<label class="wf-notify"><input type="checkbox" id="wfWebhook" checked> <strong>' + (p.trigger.kind === 'schedule' ? 'Also start it from a webhook: ' : 'Webhook: ') + esc(w.name) + '</strong></label>' +
250
+ '<span class="hint">' + esc(who) + ' calls a private URL and each event starts a run. It' + String.fromCharCode(8217) + 's made turned off; after Create you paste the URL into ' + esc(w.sender === 'custom' ? 'your app' : who) + ' and add the signing secret.</span>' +
251
+ (lines.length ? '<ul class="hint">' + lines.map(function (l) { return '<li>' + l + '</li>'; }).join('') + '</ul>' : '') +
252
+ (w.setup ? '<span class="hint">' + esc(w.setup) + '</span>' : '') +
253
+ '</div>';
254
+ }
255
+
231
256
  function renderPlan(error) {
232
257
  var p = state.planned.plan;
233
258
  var suggested = p.target.kind === 'existing' && p.target.agent ? p.target.agent : '';
@@ -236,7 +261,8 @@ ${consoleShellScript}
236
261
  '<div class="wf-card">' +
237
262
  '<h2>' + esc(p.name) + '</h2><p style="margin:0">' + esc(p.summary) + '</p>' +
238
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>' +
239
- (p.trigger.kind === 'schedule'
264
+ webhookPlanHtml(p) +
265
+ (p.trigger.kind === 'schedule' || p.trigger.kind === 'webhook'
240
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>' +
241
267
  (p.notify && p.notify.reason ? '<p class="hint" style="margin:0 0 0 24px">' + esc(p.notify.reason) + '</p>' : '')
242
268
  : '') +
@@ -279,6 +305,11 @@ ${consoleShellScript}
279
305
  p.parameters = edited.parameters;
280
306
  var notifyBox = document.getElementById('wfNotify');
281
307
  if (notifyBox) p.notify = { on: notifyBox.checked, reason: (p.notify && p.notify.reason) || '' };
308
+ var webhookBox = document.getElementById('wfWebhook');
309
+ if (webhookBox && !webhookBox.checked) {
310
+ delete p.webhook;
311
+ if (p.trigger.kind === 'webhook') p.trigger.kind = 'on_request';
312
+ }
282
313
  var chosen = page.querySelector('input[name="wfTarget"]:checked');
283
314
  var target = chosen.value === 'new' ? { kind: 'new' } : { kind: 'existing', agent: chosen.value === 'other' ? document.getElementById('wfOther').value : chosen.dataset.agent };
284
315
  var btn = document.getElementById('wfCreate');
@@ -303,6 +334,7 @@ ${consoleShellScript}
303
334
  '<p style="margin:0">' + (c.newAgent ? 'Created as a new agent, <code>' + esc(c.agent) + '</code>.' : 'Added to <code>' + esc(c.agent) + '</code>.') + '</p>' +
304
335
  (c.schedule ? '<p class="hint" style="margin:0">Its schedule (' + esc(c.schedule.description) + ') is paused: turn it on in the agent' + String.fromCharCode(8217) + 's Schedules tab when you' + String.fromCharCode(8217) + 're ready.</p>' : '') +
305
336
  (c.scheduleError ? '<p class="error">The schedule could not be created: ' + esc(c.scheduleError) + '</p>' : '') +
337
+ webhookDoneHtml(c) +
306
338
  (c.loadError ? '<p class="error">The agent did not load: ' + esc(c.loadError) + '</p>' : '') +
307
339
  (c.abilities.failed.length ? '<p class="error">Not installed: ' + c.abilities.failed.map(function (f) { return esc(f.name) + ' (' + esc(f.error) + ')'; }).join('; ') + '</p>' : '') +
308
340
  (c.keptDecisions.length ? '<p class="hint" style="margin:0"><strong>Kept as they were:</strong> ' + c.keptDecisions.map(function (k) { return '<code>' + esc(k.tool) + '</code> stays ' + esc(LABELS[k.has]) + ' (the workflow wanted ' + esc(LABELS[k.wants]) + ')'; }).join('; ') + '. Change them in Permissions if you want.</p>' : '') +
@@ -311,6 +343,65 @@ ${consoleShellScript}
311
343
  '<button type="button" class="wf-btn plain" id="wfAnother">Describe another</button></div>' +
312
344
  '</div>';
313
345
  document.getElementById('wfAnother').addEventListener('click', function () { state.description = ''; state.planned = null; renderDescribe(); });
346
+ wireWebhookDone(c);
347
+ }
348
+
349
+ // The webhook made with the workflow: its URL (shown this once), where
350
+ // to paste it, and the signing secret that turns it on.
351
+ function webhookDoneHtml(c) {
352
+ if (c.webhookError) return '<p class="error">The webhook could not be made: ' + esc(c.webhookError) + '</p>';
353
+ if (!c.webhook) return '';
354
+ var t = c.webhook.trigger;
355
+ var who = SENDERS[t.verify.sender] || t.verify.sender;
356
+ var custom = t.verify.sender === 'custom';
357
+ return '<div class="wf-hook" id="wfHookDone">' +
358
+ '<strong>Turn on the webhook: ' + esc(t.name) + '</strong>' +
359
+ '<span>1. Copy this URL into ' + esc(custom ? 'your app' : who + String.fromCharCode(8217) + 's webhook settings') + '. It' + String.fromCharCode(8217) + 's shown only this once; make a new one on the workflow if it' + String.fromCharCode(8217) + 's lost.</span>' +
360
+ '<div class="wf-hook-row"><input id="wfHookUrl" readonly value="' + esc(c.webhook.url) + '"><button type="button" class="wf-btn plain" id="wfHookCopy">Copy</button></div>' +
361
+ (c.webhook.setup ? '<span class="hint">' + esc(c.webhook.setup) + '</span>' : '') +
362
+ '<span>2. ' + (custom
363
+ ? 'Choose a signing secret. Your app signs each request body with it (HMAC-SHA256, hex) in the <code>' + esc(t.verify.header || 'X-Signature') + '</code> header.'
364
+ : 'Paste the signing secret ' + esc(who) + ' shows for this webhook.') + '</span>' +
365
+ (custom && !t.verify.header ? '<div class="wf-hook-row"><input id="wfHookHeader" placeholder="Signature header, e.g. X-Signature" value="X-Signature"></div>' : '') +
366
+ '<div class="wf-hook-row"><input id="wfHookSecret" type="password" autocomplete="off" placeholder="Signing secret"><button type="button" class="wf-btn" id="wfHookOn">Turn it on</button></div>' +
367
+ '<span class="hint" id="wfHookStatus">Until then it' + String.fromCharCode(8217) + 's off: nothing that reaches the URL starts a run.</span>' +
368
+ '</div>';
369
+ }
370
+
371
+ function wireWebhookDone(c) {
372
+ if (!c.webhook) return;
373
+ var t = c.webhook.trigger;
374
+ var status = document.getElementById('wfHookStatus');
375
+ document.getElementById('wfHookCopy').addEventListener('click', function () {
376
+ var input = document.getElementById('wfHookUrl');
377
+ input.select();
378
+ if (navigator.clipboard) navigator.clipboard.writeText(input.value);
379
+ status.textContent = 'Copied.';
380
+ });
381
+ document.getElementById('wfHookOn').addEventListener('click', function () {
382
+ var secret = document.getElementById('wfHookSecret').value.trim();
383
+ if (!secret) { status.textContent = 'Paste the signing secret first.'; return; }
384
+ var verify = { sender: t.verify.sender, secret: secret };
385
+ if (t.verify.sender === 'custom') {
386
+ var headerInput = document.getElementById('wfHookHeader');
387
+ verify.header = headerInput ? headerInput.value.trim() : t.verify.header;
388
+ verify.algorithm = t.verify.algorithm || 'sha256';
389
+ verify.encoding = t.verify.encoding || 'hex';
390
+ if (t.verify.prefix) verify.prefix = t.verify.prefix;
391
+ }
392
+ var btn = document.getElementById('wfHookOn');
393
+ btn.disabled = true;
394
+ status.textContent = 'Turning it on' + ELLIPSIS;
395
+ fetch('/agents/' + encodeURIComponent(c.agent) + '/workflows/' + encodeURIComponent(t.workflow_id) + '/triggers/' + encodeURIComponent(t.trigger_id), {
396
+ method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ verify: verify, enabled: true }),
397
+ }).then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); }).then(function (r) {
398
+ btn.disabled = false;
399
+ if (!r.ok) { status.textContent = r.body.error || 'Could not turn it on.'; return; }
400
+ document.getElementById('wfHookSecret').value = '';
401
+ status.textContent = 'On. ' + (t.filter ? 'Each ' + t.filter.values.join(' or ') + ' event' : 'Each event') + ' that reaches the URL now starts a run; deliveries show on the workflow' + String.fromCharCode(8217) + 's Webhooks.';
402
+ btn.textContent = 'Update the secret';
403
+ }).catch(function (err) { btn.disabled = false; status.textContent = err.message; });
404
+ });
314
405
  }
315
406
 
316
407
  fetch('/workflows', { cache: 'no-store' })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -124,6 +124,12 @@
124
124
  "dist/core/durable-approvals.d.ts",
125
125
  "dist/core/workflows.js",
126
126
  "dist/core/workflows.d.ts",
127
+ "dist/core/workflow-runs.js",
128
+ "dist/core/workflow-runs.d.ts",
129
+ "dist/core/run-callbacks.js",
130
+ "dist/core/run-callbacks.d.ts",
131
+ "dist/core/workflow-triggers.js",
132
+ "dist/core/workflow-triggers.d.ts",
127
133
  "dist/web",
128
134
  "protocol/loop-event.schema.json",
129
135
  "PROTOCOL.md",