loopengine 0.1.91 → 0.1.93

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/README.md CHANGED
@@ -157,7 +157,7 @@ Six channels ship as real, working notifiers — configure one and the
157
157
  | `webhook` | Signed HMAC-SHA256 POST to any URL you own |
158
158
  | `slack` | An interactive message via `chat.postMessage`, with Approve/Deny buttons |
159
159
  | `lark` | A Lark/Feishu interactive card |
160
- | `email` | A signed, expiring magic-link per decision |
160
+ | `email` | An email per decision, through your own `sendEmail`: with signed, expiring magic links to your endpoints, or a link to this server's decision page |
161
161
  | `database` | Writes a row for your own worker/dashboard to poll |
162
162
  | `redis` | Pushes a queue entry for your own worker to consume |
163
163
 
@@ -173,7 +173,9 @@ export const config: AgentConfig = {
173
173
  ```
174
174
 
175
175
  Or skip the code: in the console, the agent's **Messaging** tab sets a
176
- webhook, Slack or Lark target for one agent (saved to
176
+ webhook, Slack, Lark or email target for one agent (email sends through
177
+ Resend, SendGrid or Postmark with your API key, each email linking to the
178
+ decision page) (saved to
177
179
  `agents/<name>/messaging.yml`, with its secret in the agent's own `.env`;
178
180
  an older `notifier.yml` is still read), sends a test and shows recent
179
181
  deliveries. With Slack, people can also reply in a request's thread to
@@ -205,6 +207,14 @@ first, and a paused schedule if it runs on a timer, plus a checklist of
205
207
  what's left to connect. The interviewer runs on the model of one of your
206
208
  agents, can't see your systems, and creates nothing until you click.
207
209
 
210
+ Or skip the interview: **Templates** (`/templates`, linked from the
211
+ sidebar and the Agents page) are ready-made agents: answer customer
212
+ emails, refund approvals, triage incidents, check expense claims, lead
213
+ follow-up, inbox to tasks, and a weekly research digest. Preview one's instructions and setup checklist, then create it
214
+ the same way, as many times as you like. Templates live in
215
+ `core/system-agents/templates.ts`, in the same shape the interview
216
+ suggests.
217
+
208
218
  Every notification links to a **decision page**: a signed, single-use
209
219
  link (`/decide/<token>`, valid 7 days) where whoever got the message can
210
220
  approve, deny, edit the arguments or answer a question, with no login and
@@ -240,8 +250,10 @@ under the hood.
240
250
  ## Scheduled tasks
241
251
 
242
252
  The server runs one scheduler (`core/scheduler.ts`) that sends a message
243
- to an agent later — once (`run_at`) or on an interval (`every: "1h"`) —
244
- from the console's **Schedules** tab, the `GET/POST
253
+ to an agent later — once (`run_at`) or on an interval (`every: "1h"`),
254
+ optionally from a set first run (`start_at`), which every later run keeps:
255
+ `every: "1d"` from a 9:00 start runs daily at 9:00, `every: "7d"` from a
256
+ Monday runs on Mondays — from the console's **Schedules** tab, the `GET/POST
245
257
  /agents/<name>/schedules` API, or the agent itself: every agent has
246
258
  built-in `system_schedule_task`, `system_list_scheduled_tasks`,
247
259
  `system_check_scheduled_task` and `system_cancel_scheduled_task` tools,
package/dist/bin/cli.js CHANGED
@@ -37,9 +37,10 @@ const MODEL_ENV_VAR = {
37
37
  // literal in generated code — an operator-supplied systemPrompt (from
38
38
  // the admin UI's Create new agent form, say) containing a literal quote
39
39
  // or backslash would otherwise produce invalid, or silently different,
40
- // generated TypeScript.
40
+ // generated TypeScript. Line breaks too: a multi-line prompt (from a
41
+ // template or the interview) would otherwise end the literal mid-line.
41
42
  function tsStringLiteral(value) {
42
- return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
43
+ return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r/g, '\\r').replace(/\n/g, '\\n') + "'";
43
44
  }
44
45
  /** 'loopengine' for a real consumer project — the only thing that ever
45
46
  * resolves once this package is genuinely installed as a dependency. A
@@ -91,7 +91,14 @@ export type ApprovalOnlyHttpNotifierEvent = 'approval';
91
91
  * see `core/http-notify-triggers/email.ts`'s own doc comment for why
92
92
  * (an SMTP client/provider SDK is a dependency this package deliberately
93
93
  * doesn't carry). */
94
- export type SendEmail = (to: string, subject: string, html: string) => Promise<void>;
94
+ export type SendEmail = (to: string, subject: string, html: string, meta?: SendEmailMeta) => Promise<void>;
95
+ /** What an email is about, passed as `SendEmail`'s optional 4th argument
96
+ * so a sender can log it (core/email-senders.ts records deliveries with
97
+ * it). A sender that doesn't care can ignore it. */
98
+ export interface SendEmailMeta {
99
+ kind: 'approval' | 'question' | 'run_start' | 'run_finish' | 'test';
100
+ pendingId?: string;
101
+ }
95
102
  /** One row `channel: 'database'` (`core/http-notify-triggers/database.ts`)
96
103
  * inserts per pending approval — implement `insert()` against whatever
97
104
  * real database/ORM you already have (a single INSERT, a Prisma/Drizzle
@@ -159,9 +166,13 @@ export interface PendingApprovalsRepository {
159
166
  * `http-notify-triggers/lark.ts`'s `LarkNotifier` (lower
160
167
  * confidence on exact card-schema field names — see that file's own
161
168
  * header comment).
162
- * - `'email'` — a `sendEmail` callback plus a signed, expiring
163
- * magic-link token per approval/question, via
164
- * `http-notify-triggers/email.ts`'s `EmailNotifier`.
169
+ * - `'email'` — a `sendEmail` callback, via
170
+ * `http-notify-triggers/email.ts`'s `EmailNotifier`. With
171
+ * `resolveBaseUrl`/`answerBaseUrl`/`signingSecret`, each email carries
172
+ * its own signed magic links to *your* endpoints; without them, it
173
+ * links to this server's own decision page (`/decide/<token>`, at
174
+ * `publicUrl`), the same page Slack and Lark messages link to — what
175
+ * the console's Messaging tab sets up.
165
176
  * - `'database'`/`'redis'` — not a notification channel at all (see
166
177
  * `ApprovalOnlyHttpNotifierEvent`'s own doc comment): a row insert or
167
178
  * a queue push for a separate worker/dashboard to poll, via
@@ -213,10 +224,11 @@ export type HttpNotifierConfig = {
213
224
  config: {
214
225
  to: string;
215
226
  sendEmail: SendEmail;
216
- resolveBaseUrl: string;
217
- answerBaseUrl: string;
218
- signingSecret: string;
227
+ resolveBaseUrl?: string;
228
+ answerBaseUrl?: string;
229
+ signingSecret?: string;
219
230
  linkTtlMs?: number;
231
+ publicUrl?: string;
220
232
  };
221
233
  events: HttpNotifierEvent[];
222
234
  } | {
@@ -0,0 +1,26 @@
1
+ import type { SendEmail } from '#core/agent-config.js';
2
+ export type EmailProvider = 'resend' | 'sendgrid' | 'postmark';
3
+ export declare const EMAIL_PROVIDERS: EmailProvider[];
4
+ /** "Ops <ops@example.com>" or "ops@example.com". */
5
+ export declare function parseAddress(value: string): {
6
+ name?: string;
7
+ email: string;
8
+ } | undefined;
9
+ /** A comma- or semicolon-separated list of addresses; undefined when any
10
+ * of them isn't a valid address. */
11
+ export declare function parseRecipients(value: string): string[] | undefined;
12
+ /** A plain-text version of the HTML body, for mail clients (and spam
13
+ * filters) that prefer one. */
14
+ export declare function htmlToText(html: string): string;
15
+ export interface ProviderSenderOptions {
16
+ provider: EmailProvider;
17
+ apiKey: string;
18
+ /** The sender, e.g. "LoopEngine <approvals@example.com>"; the provider
19
+ * must allow sending from its domain. */
20
+ from: string;
21
+ /** For the deliveries list. */
22
+ agent: string;
23
+ }
24
+ /** A SendEmail that sends through `provider`. Throws when the provider
25
+ * refuses the email, after recording the failure. */
26
+ export declare function createProviderSendEmail(options: ProviderSenderOptions): SendEmail;
@@ -0,0 +1,108 @@
1
+ import { recordDelivery } from '#core/notifier-deliveries.js';
2
+ export const EMAIL_PROVIDERS = ['resend', 'sendgrid', 'postmark'];
3
+ const ENDPOINTS = {
4
+ resend: 'https://api.resend.com/emails',
5
+ sendgrid: 'https://api.sendgrid.com/v3/mail/send',
6
+ postmark: 'https://api.postmarkapp.com/email',
7
+ };
8
+ const ADDRESS = /^[^\s@<>,"]+@[^\s@<>,"]+\.[^\s@<>,"]+$/;
9
+ /** "Ops <ops@example.com>" or "ops@example.com". */
10
+ export function parseAddress(value) {
11
+ const trimmed = value.trim();
12
+ const named = /^(.*?)\s*<([^<>]+)>$/.exec(trimmed);
13
+ const email = (named ? named[2] : trimmed).trim();
14
+ if (!ADDRESS.test(email))
15
+ return undefined;
16
+ const name = named?.[1].replace(/^"|"$/g, '').trim();
17
+ return name ? { name, email } : { email };
18
+ }
19
+ /** A comma- or semicolon-separated list of addresses; undefined when any
20
+ * of them isn't a valid address. */
21
+ export function parseRecipients(value) {
22
+ const parts = value.split(/[,;]/).map((p) => p.trim()).filter(Boolean);
23
+ if (!parts.length || parts.some((p) => !parseAddress(p)))
24
+ return undefined;
25
+ return parts;
26
+ }
27
+ /** A plain-text version of the HTML body, for mail clients (and spam
28
+ * filters) that prefer one. */
29
+ export function htmlToText(html) {
30
+ return html
31
+ .replace(/<br\s*\/?>/gi, '\n')
32
+ .replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n')
33
+ .replace(/<a [^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '$2 ($1)')
34
+ .replace(/<[^>]+>/g, '')
35
+ .replace(/&nbsp;/g, ' ')
36
+ .replace(/&lt;/g, '<')
37
+ .replace(/&gt;/g, '>')
38
+ .replace(/&quot;/g, '"')
39
+ .replace(/&#39;/g, "'")
40
+ .replace(/&amp;/g, '&')
41
+ .replace(/[ \t]+\n/g, '\n')
42
+ .replace(/\n{3,}/g, '\n\n')
43
+ .trim();
44
+ }
45
+ function requestFor(provider, apiKey, from, to, subject, html) {
46
+ const text = htmlToText(html);
47
+ switch (provider) {
48
+ case 'resend':
49
+ return {
50
+ method: 'POST',
51
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
52
+ body: JSON.stringify({ from, to, subject, html, text }),
53
+ };
54
+ case 'sendgrid': {
55
+ const sender = parseAddress(from);
56
+ return {
57
+ method: 'POST',
58
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
59
+ body: JSON.stringify({
60
+ personalizations: [{ to: to.map((t) => parseAddress(t)) }],
61
+ from: sender,
62
+ subject,
63
+ content: [
64
+ { type: 'text/plain', value: text },
65
+ { type: 'text/html', value: html },
66
+ ],
67
+ }),
68
+ };
69
+ }
70
+ case 'postmark':
71
+ return {
72
+ method: 'POST',
73
+ headers: { 'x-postmark-server-token': apiKey, 'content-type': 'application/json', accept: 'application/json' },
74
+ body: JSON.stringify({ From: from, To: to.join(', '), Subject: subject, HtmlBody: html, TextBody: text, MessageStream: 'outbound' }),
75
+ };
76
+ }
77
+ }
78
+ /** The provider's own error message, when its response carries one. */
79
+ async function errorDetail(res) {
80
+ const body = (await res.json().catch(() => undefined));
81
+ return body?.message ?? body?.Message ?? body?.errors?.[0]?.message ?? body?.name ?? '';
82
+ }
83
+ /** A SendEmail that sends through `provider`. Throws when the provider
84
+ * refuses the email, after recording the failure. */
85
+ export function createProviderSendEmail(options) {
86
+ return async (to, subject, html, meta) => {
87
+ const recipients = parseRecipients(to);
88
+ if (!recipients)
89
+ throw new Error(`"${to}" isn't a valid list of email addresses`);
90
+ const delivery = { agent: options.agent, kind: meta?.kind ?? 'approval', url: `${options.provider}:${recipients.join(',')}`, attempt: 1, ...(meta?.pendingId ? { pendingId: meta.pendingId } : {}) };
91
+ let res;
92
+ try {
93
+ res = await fetch(ENDPOINTS[options.provider], requestFor(options.provider, options.apiKey, options.from, recipients, subject, html));
94
+ }
95
+ catch (err) {
96
+ const error = err instanceof Error ? err.message : String(err);
97
+ recordDelivery({ at: new Date().toISOString(), ...delivery, ok: false, error });
98
+ throw new Error(`${options.provider} couldn't be reached: ${error}`);
99
+ }
100
+ if (!res.ok) {
101
+ const detail = await errorDetail(res);
102
+ const error = `HTTP ${res.status}${detail ? `: ${detail}` : ''}`;
103
+ recordDelivery({ at: new Date().toISOString(), ...delivery, ok: false, status: res.status, error });
104
+ throw new Error(`${options.provider} refused the email (${error})`);
105
+ }
106
+ recordDelivery({ at: new Date().toISOString(), ...delivery, ok: true, status: res.status });
107
+ };
108
+ }
@@ -48,7 +48,7 @@ export function describeNotifier(config) {
48
48
  }
49
49
  if (!file)
50
50
  return { source: 'none' };
51
- const { missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
51
+ const { missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
52
52
  return { source: missingSecret ? 'none' : 'file', channel: file.channel, events: file.events, file, missingSecret };
53
53
  }
54
54
  /** AgentConfig.httpNotifier when the agent sets one in code; otherwise
@@ -77,7 +77,7 @@ function resolveFromFile(config) {
77
77
  }
78
78
  if (!file)
79
79
  return EMPTY;
80
- const { config: httpNotifier } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
80
+ const { config: httpNotifier } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
81
81
  return httpNotifier ? buildResolved(httpNotifier, dir) : EMPTY;
82
82
  }
83
83
  /** `agentDir`: where a Slack request's thread is recorded, so replies in
@@ -129,16 +129,16 @@ function buildResolved(httpNotifier, agentDir) {
129
129
  break;
130
130
  }
131
131
  case 'email': {
132
- const { to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs } = httpNotifier.config;
133
- const email = new EmailNotifier({ to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs });
132
+ const { to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs, publicUrl } = httpNotifier.config;
133
+ const email = new EmailNotifier({ to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs, publicUrl });
134
134
  resolved = {
135
135
  approver: events.has('approval') ? email : undefined,
136
136
  questionHandler: events.has('question') ? email : undefined,
137
137
  onRunStart: events.has('run_start')
138
- ? (context) => email.sendAnnouncement(formatEmailLifecycleSubject('run_start', context), formatChatLifecycleMessage('run_start', context))
138
+ ? (context) => email.sendAnnouncement(formatEmailLifecycleSubject('run_start', context), formatChatLifecycleMessage('run_start', context), 'run_start')
139
139
  : undefined,
140
140
  onRunFinish: events.has('run_finish')
141
- ? (context) => email.sendAnnouncement(formatEmailLifecycleSubject('run_finish', context), formatChatLifecycleMessage('run_finish', context))
141
+ ? (context) => email.sendAnnouncement(formatEmailLifecycleSubject('run_finish', context), formatChatLifecycleMessage('run_finish', context), 'run_finish')
142
142
  : undefined,
143
143
  };
144
144
  break;
@@ -19,18 +19,24 @@ export declare function verifyMagicLink(token: string, signingSecret: string): E
19
19
  export declare class EmailNotifier implements DurableApprover, DurableQuestionHandler {
20
20
  private readonly to;
21
21
  private readonly sendEmail;
22
- private readonly resolveBaseUrl;
23
- private readonly answerBaseUrl;
24
- private readonly signingSecret;
22
+ /** Magic-link mode: links to YOUR endpoints, signed with signingSecret. */
23
+ private readonly magic?;
24
+ /** Decision-page mode: where this server is reachable. */
25
+ private readonly publicUrl?;
25
26
  private readonly linkTtlMs;
26
27
  constructor(options: {
27
28
  to: string;
28
29
  sendEmail: SendEmail;
29
- resolveBaseUrl: string;
30
- answerBaseUrl: string;
31
- signingSecret: string;
30
+ resolveBaseUrl?: string;
31
+ answerBaseUrl?: string;
32
+ signingSecret?: string;
32
33
  linkTtlMs?: number;
34
+ publicUrl?: string;
33
35
  });
36
+ private expiryNote;
37
+ /** Decision-page mode: one link to decide (or answer) on this server,
38
+ * or where to find the request when the server's address isn't known. */
39
+ private decideHtml;
34
40
  requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
35
41
  pendingId: string;
36
42
  };
@@ -39,6 +45,6 @@ export declare class EmailNotifier implements DurableApprover, DurableQuestionHa
39
45
  };
40
46
  /** Public: core/http-notifier.ts's own onRunStart/onRunFinish lifecycle
41
47
  * sender for `channel: 'email'` calls this directly — a plain
42
- * announcement, no magic link, since there's nothing to resolve. */
43
- sendAnnouncement(subject: string, text: string): void;
48
+ * announcement, no link, since there's nothing to resolve. */
49
+ sendAnnouncement(subject: string, text: string, kind?: 'run_start' | 'run_finish' | 'test'): void;
44
50
  }
@@ -31,7 +31,15 @@
31
31
  // file builds one: a small HMAC-signed, expiring token embedded in the
32
32
  // link itself, verified before ever touching loopengine's own
33
33
  // resolve/answer routes.
34
+ //
35
+ // Without resolveBaseUrl/answerBaseUrl/signingSecret it needs none of
36
+ // that: each email links to this server's own decision page
37
+ // (core/approval-links.ts, /decide/<token>), the same page Slack and Lark
38
+ // messages link to, where the person approves, denies, edits or answers.
39
+ // That's what an email channel set from the console's Messaging tab uses.
34
40
  import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
41
+ import { decidePath, decisionLinkTtlMs } from '../approval-links.js';
42
+ import { publicBaseUrl } from './webhook.js';
35
43
  function signMagicLink(payload, expiresInMs, signingSecret) {
36
44
  const body = Buffer.from(JSON.stringify({ ...payload, exp: Date.now() + expiresInMs })).toString('base64url');
37
45
  const signature = createHmac('sha256', signingSecret).update(body).digest('base64url');
@@ -67,22 +75,51 @@ function escapeHtml(s) {
67
75
  export class EmailNotifier {
68
76
  to;
69
77
  sendEmail;
70
- resolveBaseUrl; // e.g. https://yourapp.com/email/approvals — YOUR route, not loopengine's own address
71
- answerBaseUrl; // e.g. https://yourapp.com/email/questions — YOUR route, not loopengine's own address
72
- signingSecret;
78
+ /** Magic-link mode: links to YOUR endpoints, signed with signingSecret. */
79
+ magic;
80
+ /** Decision-page mode: where this server is reachable. */
81
+ publicUrl;
73
82
  linkTtlMs;
74
83
  constructor(options) {
75
84
  this.to = options.to;
76
85
  this.sendEmail = options.sendEmail;
77
- this.resolveBaseUrl = options.resolveBaseUrl;
78
- this.answerBaseUrl = options.answerBaseUrl;
79
- this.signingSecret = options.signingSecret;
80
- this.linkTtlMs = options.linkTtlMs ?? 3 * 24 * 60 * 60_000; // 3 days — long enough for someone to actually check email, short enough that a leaked link doesn't stay live forever
86
+ const { resolveBaseUrl, answerBaseUrl, signingSecret } = options;
87
+ const given = [resolveBaseUrl, answerBaseUrl, signingSecret].filter(Boolean).length;
88
+ if (given > 0 && given < 3) {
89
+ throw new Error('EmailNotifier: give resolveBaseUrl, answerBaseUrl and signingSecret together (magic links), or none of them (decision-page links)');
90
+ }
91
+ if (resolveBaseUrl && answerBaseUrl && signingSecret)
92
+ this.magic = { resolveBaseUrl, answerBaseUrl, signingSecret };
93
+ this.publicUrl = options.publicUrl;
94
+ // Magic links: 3 days — long enough for someone to actually check
95
+ // email, short enough that a leaked link doesn't stay live forever.
96
+ // Decision-page links last as long as the decision page allows.
97
+ this.linkTtlMs = options.linkTtlMs ?? (this.magic ? 3 * 24 * 60 * 60_000 : decisionLinkTtlMs());
98
+ }
99
+ expiryNote() {
100
+ const hours = Math.round(this.linkTtlMs / 3_600_000);
101
+ return `<p style="color:#888;font-size:12px">This link expires in ${hours >= 48 ? `${Math.round(hours / 24)} days` : `${hours} hours`}.</p>`;
102
+ }
103
+ /** Decision-page mode: one link to decide (or answer) on this server,
104
+ * or where to find the request when the server's address isn't known. */
105
+ decideHtml(pendingId, kind) {
106
+ const base = publicBaseUrl(this.publicUrl);
107
+ if (!base)
108
+ return `<p>Open this agent's Messaging tab in the LoopEngine console to ${kind === 'approval' ? 'approve or deny it' : 'answer'}.</p>`;
109
+ return `<p><a href="${base}${decidePath(pendingId, kind)}">${kind === 'approval' ? 'Review and approve or deny' : 'Answer'}</a></p>` + this.expiryNote();
81
110
  }
82
111
  requestDurableApproval(tool, args, scope, reason) {
83
112
  const pendingId = randomUUID();
84
- const approveLink = `${this.resolveBaseUrl}?token=${signMagicLink({ pendingId, decision: 'approve' }, this.linkTtlMs, this.signingSecret)}`;
85
- const denyLink = `${this.resolveBaseUrl}?token=${signMagicLink({ pendingId, decision: 'deny' }, this.linkTtlMs, this.signingSecret)}`;
113
+ let links;
114
+ if (this.magic) {
115
+ const { resolveBaseUrl, signingSecret } = this.magic;
116
+ const approveLink = `${resolveBaseUrl}?token=${signMagicLink({ pendingId, decision: 'approve' }, this.linkTtlMs, signingSecret)}`;
117
+ const denyLink = `${resolveBaseUrl}?token=${signMagicLink({ pendingId, decision: 'deny' }, this.linkTtlMs, signingSecret)}`;
118
+ links = `<p><a href="${approveLink}">Approve</a> &nbsp;|&nbsp; <a href="${denyLink}">Deny</a></p>` + this.expiryNote();
119
+ }
120
+ else {
121
+ links = this.decideHtml(pendingId, 'approval');
122
+ }
86
123
  // Not awaited — requestDurableApproval returns immediately, by
87
124
  // contract (see DurableApprover's own doc comment). Logged instead of
88
125
  // thrown on failure.
@@ -90,37 +127,42 @@ export class EmailNotifier {
90
127
  `<p><b>Args:</b> ${escapeHtml(JSON.stringify(args))}</p>` +
91
128
  `<p><b>Scope:</b> ${escapeHtml(`${scope.tenant}/${scope.environment}/${scope.agent}`)}</p>` +
92
129
  `<p><b>Reason:</b> ${escapeHtml(reason)}</p>` +
93
- `<p><a href="${approveLink}">Approve</a> &nbsp;|&nbsp; <a href="${denyLink}">Deny</a></p>` +
94
- `<p style="color:#888;font-size:12px">This link expires in ${Math.round(this.linkTtlMs / 3_600_000)} hours.</p>`).catch((err) => {
130
+ links, { kind: 'approval', pendingId }).catch((err) => {
95
131
  console.error(`[loopengine] EmailNotifier: sendEmail failed for pendingId '${pendingId}':`, err);
96
132
  });
97
133
  return { pendingId };
98
134
  }
99
135
  notifyPendingQuestion(question, options, agent, sessionId) {
100
136
  const pendingId = randomUUID();
101
- const optionLinks = (options ?? [])
102
- .map((option) => {
103
- const token = signMagicLink({ pendingId, answer: option }, this.linkTtlMs, this.signingSecret);
104
- return `<a href="${this.answerBaseUrl}?token=${token}">${escapeHtml(option)}</a>`;
105
- })
106
- .join(' &nbsp;|&nbsp; ');
107
- const freeTextToken = signMagicLink({ pendingId }, this.linkTtlMs, this.signingSecret);
108
- const freeTextLink = `${this.answerBaseUrl}?token=${freeTextToken}`;
109
- this.sendEmail(this.to, `Question from ${agent}`, `<p>${escapeHtml(question)}</p>` +
110
- (sessionId ? `<p style="color:#888;font-size:12px">Session: ${escapeHtml(sessionId)}</p>` : '') +
111
- (optionLinks ? `<p>${optionLinks}</p>` : '') +
112
- `<p><a href="${freeTextLink}">${optionLinks ? 'Or answer in your own words' : 'Answer'}</a></p>` +
113
- `<p style="color:#888;font-size:12px">This link expires in ${Math.round(this.linkTtlMs / 3_600_000)} hours.</p>`).catch((err) => {
137
+ let links;
138
+ if (this.magic) {
139
+ const { answerBaseUrl, signingSecret } = this.magic;
140
+ const optionLinks = (options ?? [])
141
+ .map((option) => {
142
+ const token = signMagicLink({ pendingId, answer: option }, this.linkTtlMs, signingSecret);
143
+ return `<a href="${answerBaseUrl}?token=${token}">${escapeHtml(option)}</a>`;
144
+ })
145
+ .join(' &nbsp;|&nbsp; ');
146
+ const freeTextToken = signMagicLink({ pendingId }, this.linkTtlMs, signingSecret);
147
+ const freeTextLink = `${answerBaseUrl}?token=${freeTextToken}`;
148
+ links = (optionLinks ? `<p>${optionLinks}</p>` : '') + `<p><a href="${freeTextLink}">${optionLinks ? 'Or answer in your own words' : 'Answer'}</a></p>` + this.expiryNote();
149
+ }
150
+ else {
151
+ // The decision page offers the suggested answers itself.
152
+ const suggested = (options ?? []).length ? `<p>Suggested answers: ${(options ?? []).map(escapeHtml).join(', ')}</p>` : '';
153
+ links = suggested + this.decideHtml(pendingId, 'question');
154
+ }
155
+ this.sendEmail(this.to, `Question from ${agent}`, `<p>${escapeHtml(question)}</p>` + (sessionId ? `<p style="color:#888;font-size:12px">Session: ${escapeHtml(sessionId)}</p>` : '') + links, { kind: 'question', pendingId }).catch((err) => {
114
156
  console.error(`[loopengine] EmailNotifier: sendEmail failed for pendingId '${pendingId}':`, err);
115
157
  });
116
158
  return { pendingId };
117
159
  }
118
160
  /** Public: core/http-notifier.ts's own onRunStart/onRunFinish lifecycle
119
161
  * sender for `channel: 'email'` calls this directly — a plain
120
- * announcement, no magic link, since there's nothing to resolve. */
121
- sendAnnouncement(subject, text) {
122
- this.sendEmail(this.to, subject, `<p>${escapeHtml(text)}</p>`).catch((err) => {
123
- console.error('[loopengine] EmailNotifier: sendEmail failed for lifecycle announcement:', err);
162
+ * announcement, no link, since there's nothing to resolve. */
163
+ sendAnnouncement(subject, text, kind = 'run_finish') {
164
+ this.sendEmail(this.to, subject, `<p>${escapeHtml(text)}</p>`, { kind }).catch((err) => {
165
+ console.error('[loopengine] EmailNotifier: sendEmail failed for an announcement:', err);
124
166
  });
125
167
  }
126
168
  }
@@ -1,5 +1,6 @@
1
1
  import type { AgentEnv, HttpNotifierConfig, HttpNotifierEvent } from '#core/agent-config.js';
2
- export type NotifierFileChannel = 'webhook' | 'slack' | 'lark';
2
+ import { type EmailProvider } from '#core/email-senders.js';
3
+ export type NotifierFileChannel = 'webhook' | 'slack' | 'lark' | 'email';
3
4
  export interface NotifierFile {
4
5
  channel: NotifierFileChannel;
5
6
  events: HttpNotifierEvent[];
@@ -10,6 +11,12 @@ export interface NotifierFile {
10
11
  /** lark: app id and chat to post into. */
11
12
  appId?: string;
12
13
  chatId?: string;
14
+ /** email: who receives requests (comma-separated), who they come from
15
+ * ("LoopEngine <approvals@example.com>"), and the provider that sends
16
+ * them. */
17
+ to?: string;
18
+ from?: string;
19
+ provider?: EmailProvider;
13
20
  /** Where this server is reachable, for the links in each message —
14
21
  * remembered from the console's own address when saving.
15
22
  * LOOPENGINE_PUBLIC_URL, when set, wins over it. */
@@ -48,8 +55,9 @@ export declare function writeNotifierFile(dir: string, input: unknown): Notifier
48
55
  export declare function removeNotifierFile(dir: string): void;
49
56
  /** The HttpNotifierConfig a connection describes, with its secret read
50
57
  * from `env` — or which secret is missing, so nothing is sent unsigned
51
- * or unauthenticated. */
52
- export declare function notifierConfigFromFile(file: NotifierFile, env: AgentEnv): {
58
+ * or unauthenticated. `agentName` labels email deliveries in the
59
+ * console's list. */
60
+ export declare function notifierConfigFromFile(file: NotifierFile, env: AgentEnv, agentName?: string): {
53
61
  config?: HttpNotifierConfig;
54
62
  missingSecret?: string;
55
63
  };
@@ -18,24 +18,26 @@
18
18
  // messaging.yml.
19
19
  //
20
20
  // The file holds only non-secret settings. The one secret each channel
21
- // needs (a webhook signing secret, a Slack bot token, a Lark app secret)
21
+ // needs (a webhook signing secret, a Slack bot token, a Lark app secret, an email provider's API key)
22
22
  // lives in the agent's own env (agents/<name>/.env by default, through
23
23
  // core/secret-store.ts) under a fixed name, so it's never written into a
24
24
  // file that might get committed.
25
25
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
28
+ import { EMAIL_PROVIDERS, createProviderSendEmail, parseAddress, parseRecipients } from '#core/email-senders.js';
28
29
  /** The env var each channel's secret is read from, per agent. */
29
30
  export const NOTIFIER_SECRET_VARS = {
30
31
  webhook: 'LOOPENGINE_NOTIFIER_WEBHOOK_SECRET',
31
32
  slack: 'LOOPENGINE_NOTIFIER_SLACK_BOT_TOKEN',
32
33
  lark: 'LOOPENGINE_NOTIFIER_LARK_APP_SECRET',
34
+ email: 'LOOPENGINE_NOTIFIER_EMAIL_API_KEY',
33
35
  };
34
36
  /** Optional: a Slack app's signing secret, per agent. Set, it turns on
35
37
  * Approve/Deny buttons in Slack, handled by the server itself at
36
38
  * POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
37
39
  export const NOTIFIER_SLACK_SIGNING_SECRET_VAR = 'LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET';
38
- const CHANNELS = ['webhook', 'slack', 'lark'];
40
+ const CHANNELS = ['webhook', 'slack', 'lark', 'email'];
39
41
  const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
40
42
  export class NotifierFileError extends Error {
41
43
  }
@@ -99,7 +101,7 @@ export function validateNotifierFile(input) {
99
101
  throw new NotifierFileError('channelId is required for slack');
100
102
  file.channelId = channelId;
101
103
  }
102
- else {
104
+ else if (channel === 'lark') {
103
105
  const appId = str('appId');
104
106
  const chatId = str('chatId');
105
107
  if (!appId || !chatId)
@@ -107,6 +109,21 @@ export function validateNotifierFile(input) {
107
109
  file.appId = appId;
108
110
  file.chatId = chatId;
109
111
  }
112
+ else {
113
+ const to = str('to');
114
+ const recipients = to ? parseRecipients(to) : undefined;
115
+ if (!recipients)
116
+ throw new NotifierFileError('to must be one or more email addresses, separated by commas');
117
+ const from = str('from');
118
+ if (!from || !parseAddress(from))
119
+ throw new NotifierFileError('from must be an email address, like "LoopEngine <approvals@example.com>"');
120
+ const provider = str('provider');
121
+ if (!provider || !EMAIL_PROVIDERS.includes(provider))
122
+ throw new NotifierFileError(`provider must be one of: ${EMAIL_PROVIDERS.join(', ')}`);
123
+ file.to = recipients.join(', ');
124
+ file.from = from;
125
+ file.provider = provider;
126
+ }
110
127
  return file;
111
128
  }
112
129
  /** The agent's connection: messaging.yml's first, else the older
@@ -147,8 +164,9 @@ export function removeNotifierFile(dir) {
147
164
  }
148
165
  /** The HttpNotifierConfig a connection describes, with its secret read
149
166
  * from `env` — or which secret is missing, so nothing is sent unsigned
150
- * or unauthenticated. */
151
- export function notifierConfigFromFile(file, env) {
167
+ * or unauthenticated. `agentName` labels email deliveries in the
168
+ * console's list. */
169
+ export function notifierConfigFromFile(file, env, agentName = '') {
152
170
  const secretVar = NOTIFIER_SECRET_VARS[file.channel];
153
171
  const secret = env.get(secretVar);
154
172
  if (!secret)
@@ -178,5 +196,19 @@ export function notifierConfigFromFile(file, env) {
178
196
  // No card buttons: nothing built in handles Lark's card callbacks yet,
179
197
  // so a console-configured Lark notifier relies on the decision link.
180
198
  return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
199
+ case 'email':
200
+ // Links to the decision page, like Lark without buttons; the secret
201
+ // is the provider's API key.
202
+ return {
203
+ config: {
204
+ channel: 'email',
205
+ config: {
206
+ to: file.to,
207
+ sendEmail: createProviderSendEmail({ provider: file.provider, apiKey: secret, from: file.from, agent: agentName }),
208
+ publicUrl: file.publicUrl,
209
+ },
210
+ events: file.events,
211
+ },
212
+ };
181
213
  }
182
214
  }
@@ -73,9 +73,15 @@ export declare function setSchedulerStore(next: SchedulerStore): void;
73
73
  export interface CreateScheduledTaskInput {
74
74
  agent: string;
75
75
  message: string;
76
+ /** `start_at` (with `every` only): when the first run is, and so the
77
+ * time of day / day of week every later run keeps, e.g. every "1d"
78
+ * from "2026-11-02T09:00:00+08:00" runs daily at 9:00. A start in the
79
+ * past is fine: the first run is its next slot after now. Without it,
80
+ * the first run is one interval from now. */
76
81
  schedule: {
77
82
  run_at?: unknown;
78
83
  every?: unknown;
84
+ start_at?: unknown;
79
85
  };
80
86
  task_name?: string;
81
87
  notify?: boolean;