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
@@ -0,0 +1,125 @@
1
+ // Callbacks for workflow runs started over the API: a run started with
2
+ // "callback_url" has its record posted there when it finishes, fails, or
3
+ // stops for an approval or a question (core/workflow-runs.ts calls
4
+ // deliverRunCallback for each of those).
5
+ //
6
+ // Each POST carries
7
+ // X-LoopEngine-Event: run.done | run.failed | run.awaiting_approval
8
+ // X-LoopEngine-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">
9
+ // signed with the workspace's callback secret: LOOPENGINE_CALLBACK_SECRET,
10
+ // or one made once and kept in the scheduler's folder
11
+ // (GET /runs/callback-secret shows it). A receiver checks the signature
12
+ // and that t is recent.
13
+ //
14
+ // Only https URLs on public addresses are accepted, so a run can't be
15
+ // pointed at the workspace's own network. LOOPENGINE_ALLOW_LOCAL_CALLBACKS=1
16
+ // also allows http and local addresses, for development.
17
+ import { createHmac, randomBytes } from 'node:crypto';
18
+ import { lookup } from 'node:dns/promises';
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { isIP } from 'node:net';
21
+ import path from 'node:path';
22
+ export class CallbackUrlError extends Error {
23
+ }
24
+ function allowLocal() {
25
+ return process.env.LOOPENGINE_ALLOW_LOCAL_CALLBACKS === '1';
26
+ }
27
+ /** Whether an IP address is private, loopback, link-local or otherwise
28
+ * not on the public internet. */
29
+ export function isPrivateAddress(address) {
30
+ const ip = address.toLowerCase().replace(/^\[|\]$/g, '');
31
+ const v4 = ip.startsWith('::ffff:') ? ip.slice(7) : ip;
32
+ if (isIP(v4) === 4) {
33
+ const [a, b] = v4.split('.').map(Number);
34
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
35
+ (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
36
+ }
37
+ if (isIP(ip) === 6) {
38
+ return ip === '::' || ip === '::1' || /^f[cd]/.test(ip) || /^fe[89ab]/.test(ip) || ip.startsWith('ff');
39
+ }
40
+ return false;
41
+ }
42
+ /** Checks a callback URL as given; returns it normalised. Throws
43
+ * CallbackUrlError saying what's wrong. */
44
+ export function checkCallbackUrl(raw) {
45
+ if (typeof raw !== 'string' || !raw.trim())
46
+ throw new CallbackUrlError('callback_url must be a URL, e.g. "https://example.com/hooks/loopengine"');
47
+ let url;
48
+ try {
49
+ url = new URL(raw.trim());
50
+ }
51
+ catch {
52
+ throw new CallbackUrlError(`callback_url "${raw}" isn't a URL`);
53
+ }
54
+ const host = url.hostname.toLowerCase();
55
+ const local = host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.internal') || host.endsWith('.local') || isPrivateAddress(host);
56
+ if (url.protocol !== 'https:' && !(allowLocal() && url.protocol === 'http:'))
57
+ throw new CallbackUrlError('callback_url must use https');
58
+ if (local && !allowLocal())
59
+ throw new CallbackUrlError('callback_url must be a public address, not a local or private one');
60
+ if (url.username || url.password)
61
+ throw new CallbackUrlError('callback_url must not carry a username or password; check the signature instead');
62
+ return url.toString();
63
+ }
64
+ /** The workspace's callback secret: LOOPENGINE_CALLBACK_SECRET, or one
65
+ * made once and kept in the scheduler's folder. */
66
+ export function callbackSecret() {
67
+ if (process.env.LOOPENGINE_CALLBACK_SECRET)
68
+ return process.env.LOOPENGINE_CALLBACK_SECRET;
69
+ const dir = process.env.LOOPENGINE_SCHEDULER_DIR || path.join(process.cwd(), '.scheduler');
70
+ const file = path.join(dir, 'callback-secret');
71
+ if (existsSync(file))
72
+ return readFileSync(file, 'utf8').trim();
73
+ mkdirSync(dir, { recursive: true });
74
+ const secret = randomBytes(32).toString('hex');
75
+ writeFileSync(file, secret + '\n', { mode: 0o600 });
76
+ return secret;
77
+ }
78
+ /** The signature header for a body sent at `timestamp` (unix seconds). */
79
+ export function signCallback(body, timestamp, secret = callbackSecret()) {
80
+ return `t=${timestamp},v1=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`;
81
+ }
82
+ /** Posts `payload` to `url` for `event`, retrying a failed attempt (an
83
+ * error, or a status other than 2xx) after each wait in backoffMs. The
84
+ * host is looked up first, and a private address is refused (unless
85
+ * local callbacks are allowed), so a public name can't lead inward. */
86
+ export async function deliverCallback(url, event, payload, options = {}) {
87
+ const backoff = options.backoffMs ?? [2_000, 10_000, 30_000];
88
+ const doFetch = options.fetch ?? fetch;
89
+ const body = JSON.stringify(payload);
90
+ let last = { event, delivered: false, attempts: 0, at: new Date().toISOString() };
91
+ for (let attempt = 0; attempt <= backoff.length; attempt++) {
92
+ if (attempt > 0)
93
+ await new Promise((r) => setTimeout(r, backoff[attempt - 1]));
94
+ const at = new Date().toISOString();
95
+ try {
96
+ if (!allowLocal()) {
97
+ const addresses = await lookup(new URL(url).hostname, { all: true });
98
+ if (addresses.some((a) => isPrivateAddress(a.address))) {
99
+ return { event, delivered: false, attempts: attempt + 1, error: 'The callback address leads to a private network.', at };
100
+ }
101
+ }
102
+ const timestamp = Math.floor(Date.now() / 1000);
103
+ const response = await doFetch(url, {
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/json', 'x-loopengine-event': event, 'x-loopengine-signature': signCallback(body, timestamp), 'user-agent': 'LoopEngine-Callback/1' },
106
+ body,
107
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
108
+ redirect: 'manual',
109
+ });
110
+ last = { event, delivered: response.ok, attempts: attempt + 1, status_code: response.status, at, ...(response.ok ? {} : { error: `It answered ${response.status}.` }) };
111
+ if (response.ok)
112
+ return last;
113
+ }
114
+ catch (err) {
115
+ last = { event, delivered: false, attempts: attempt + 1, error: err instanceof Error ? err.message : String(err), at };
116
+ }
117
+ }
118
+ return last;
119
+ }
120
+ /** What a run's callback carries: the event and the run's record, without
121
+ * its session id, its first message or past callback deliveries. */
122
+ export function runCallbackPayload(event, run) {
123
+ const { session_id: _session, message: _message, callbacks: _callbacks, ...rest } = run;
124
+ return { event, run: rest };
125
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ToolSchema } from '#core/agent-config.js';
2
2
  import type { WorkflowParameter } from '#core/workflows.js';
3
+ import type { TriggerSender } from '#core/workflow-triggers.js';
3
4
  import type { ModelCall } from '#core/run-agent.js';
4
5
  import type { StarterTemplate, TemplateApp } from './templates.js';
5
6
  export type Decision = 'allow' | 'ask' | 'deny';
@@ -50,7 +51,8 @@ export interface WorkflowPlan {
50
51
  id: string;
51
52
  summary: string;
52
53
  trigger: {
53
- kind: 'schedule' | 'on_request';
54
+ /** webhook: an outside service's events start runs (see `webhook`). */
55
+ kind: 'schedule' | 'on_request' | 'webhook';
54
56
  description: string;
55
57
  /** schedule only: "1h", "24h", "7d". */
56
58
  every?: string;
@@ -80,8 +82,13 @@ export interface WorkflowPlan {
80
82
  /** Values a run is given, filled into $name in the start message and
81
83
  * steps (core/workflows.ts). Empty when every run is the same. */
82
84
  parameters: Record<string, WorkflowParameter>;
83
- /** Whether each scheduled run's result is sent to the person through
84
- * the agent's messaging app, and why (shown on the review screen). */
85
+ /** A webhook that starts runs: set when the trigger is "webhook", and
86
+ * also when a scheduled workflow should run on an event too. Made on
87
+ * Create, turned off until the person adds the sender's signing secret. */
88
+ webhook?: PlannedWebhook;
89
+ /** Whether each scheduled or webhook-started run's result is sent to
90
+ * the person through the agent's messaging app, and why (shown on the
91
+ * review screen). */
85
92
  notify: {
86
93
  on: boolean;
87
94
  reason: string;
@@ -104,6 +111,25 @@ export interface WorkflowPlan {
104
111
  * use (filled in on Create, from mcpServers). */
105
112
  apps?: TemplateApp[];
106
113
  }
114
+ export interface PlannedWebhook {
115
+ /** Whose events: the sender whose signature it checks. "custom" is the
116
+ * person's own app or any other service that signs the body. */
117
+ sender: TriggerSender;
118
+ /** "Stripe: new disputes". */
119
+ name: string;
120
+ /** custom only: the header the signature comes in, when known. */
121
+ header?: string;
122
+ /** Only these events start a run: a dot path into the payload and its values. */
123
+ filter?: {
124
+ path: string;
125
+ values: string[];
126
+ };
127
+ /** Workflow parameter → dot path into the payload. */
128
+ mapping?: Record<string, string>;
129
+ /** What to choose in the sender's own webhook settings, in plain words:
130
+ * "In Stripe, listen for charge.dispute.created." */
131
+ setup?: string;
132
+ }
107
133
  export interface WorkflowPlannerTools {
108
134
  /** Every agent: its tools, installed abilities, rules and messaging. */
109
135
  listAgents(): Promise<unknown>;
@@ -125,13 +151,20 @@ export interface WorkflowPlannerTools {
125
151
  /** A server's tools, when it can be listed without a key (or with one already set). */
126
152
  mcpServerTools?(id: string): Promise<unknown>;
127
153
  }
128
- export declare const WORKFLOW_PLANNER_PROMPT = "You turn a workflow a person describes into a plan for a LoopEngine agent.\n\nLoopEngine agents call tools. Tools come from abilities (installable packages: public ones on npm, or the workspace's own private ones) or are built in. Each tool has a permission: allow (runs on its own), ask (waits for a person to approve, edit or deny it) or deny. When nobody is in Chat, questions and approvals go to the agent's messaging app (Slack, Telegram, Lark or email) and the run waits, for days if needed, then carries on.\n\nHow to plan:\n1. Look before you plan: list_agents (what exists, and what each agent's tools and rules are), list_workspace_abilities, search_public_abilities for each kind of step, ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.\n2. Split the workflow into steps, in order. For each step:\n - something to fetch or do: kind \"tool\", with the exact tool name and where it comes from. Pick the source in this order:\n a. A tool the target agent already has (from list_agents): use it, nothing to install.\n b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. \"firecrawl\" or \"web search\" for searching and reading the web). Set \"ability\" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.\n c. Only when no ability can do it, and search_mcp_servers is available: an app's MCP server. Search for the app (e.g. \"linear\", \"stripe\"), pick the app's own server (official), else a saved or verified one, then call mcp_server_tools. Set \"mcp\" to the server's id, and \"tool\" to its exact tool name when mcp_server_tools lists them (leave \"tool\" empty when it needs a key first, and say in \"detail\" what the step does). It's set up as a private ability when the workflow is created, which picks its tools and their permissions, so add no rules for MCP tools.\n - the person chooses or answers (\"ask me which\", \"let me pick\"): kind \"ask\", tool \"system_ask_user\" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.\n - the person reviews or approves before something happens (\"let me review it\", \"before it's sent\"): kind \"approval\", on the tool that does that thing; its rule becomes \"ask\".\n - the agent's own work (writing, summarising, choosing): kind \"other\".\n - nothing found can do it: set \"missing\" to what's needed, and add it to gaps.\n3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.\n4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.\n5. Trigger: a schedule (\"every Monday at 9\" is every \"7d\", at \"09:00\", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.\n6. Notify (scheduled workflows): whether each run's result is sent to the person through the agent's messaging app. true when the result is for them and no step already delivers it (\"tell me\", \"send me\", \"let me know\", a summary or findings that no step posts or emails). false when a step already sends it (posting to a channel, emailing a report): it would arrive twice. false when the work itself is the outcome (answering emails, filing tasks), where a message every run is noise; questions and approvals reach them either way. Say why in one short sentence.\n7. Parameters: if the job is about something that changes from run to run (an order, a ticket, an alert, a customer, a date range), declare it as a parameter: a short snake_case name, a one-line description, and required when a run makes no sense without it. Write $name wherever its value goes, in startMessage and in the skill (\"Check the refund request for order $order_number.\"). Apps and people fill them in when they start a run. A scheduled job that finds its own work (\"check the inbox every 10 minutes\", \"last week's sales\") needs none. Don't make fixed settings, such as the repository or the channel, parameters: write them into the skill.\n8. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.\n9. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings). Not for MCP servers: their keys are asked for on the plan.\n\nThen call save_plan once, with the whole plan. Don't ask the person questions: if something is unclear, plan the most reasonable version and note the assumption in gaps.";
154
+ export declare const WORKFLOW_PLANNER_PROMPT = "You turn a workflow a person describes into a plan for a LoopEngine agent.\n\nLoopEngine agents call tools. Tools come from abilities (installable packages: public ones on npm, or the workspace's own private ones) or are built in. Each tool has a permission: allow (runs on its own), ask (waits for a person to approve, edit or deny it) or deny. When nobody is in Chat, questions and approvals go to the agent's messaging app (Slack, Telegram, Lark or email) and the run waits, for days if needed, then carries on.\n\nHow to plan:\n1. Look before you plan: list_agents (what exists, and what each agent's tools and rules are), list_workspace_abilities, search_public_abilities for each kind of step, ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.\n2. Split the workflow into steps, in order. For each step:\n - something to fetch or do: kind \"tool\", with the exact tool name and where it comes from. Pick the source in this order:\n a. A tool the target agent already has (from list_agents): use it, nothing to install.\n b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. \"firecrawl\" or \"web search\" for searching and reading the web). Set \"ability\" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.\n c. Only when no ability can do it, and search_mcp_servers is available: an app's MCP server. Search for the app (e.g. \"linear\", \"stripe\"), pick the app's own server (official), else a saved or verified one, then call mcp_server_tools. Set \"mcp\" to the server's id, and \"tool\" to its exact tool name when mcp_server_tools lists them (leave \"tool\" empty when it needs a key first, and say in \"detail\" what the step does). It's set up as a private ability when the workflow is created, which picks its tools and their permissions, so add no rules for MCP tools.\n - the person chooses or answers (\"ask me which\", \"let me pick\"): kind \"ask\", tool \"system_ask_user\" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.\n - the person reviews or approves before something happens (\"let me review it\", \"before it's sent\"): kind \"approval\", on the tool that does that thing; its rule becomes \"ask\".\n - the agent's own work (writing, summarising, choosing): kind \"other\".\n - nothing found can do it: set \"missing\" to what's needed, and add it to gaps.\n3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.\n4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.\n5. Trigger: how a run starts. startMessage is what the agent receives when it starts.\n - schedule: a time or interval (\"every Monday at 9\" is every \"7d\", at \"09:00\", weekday 1).\n - webhook: when something happens in another service that can call a URL (\"when a Stripe dispute comes in\", \"when a GitHub issue is opened\", \"when a Shopify order is paid\", \"when our app sends a new signup\"). Fill in \"webhook\" (below). Prefer it over polling on a schedule whenever the service sends webhooks.\n - on_request: started by a person, from Chat, a message, the Run button or an app's API call.\n A workflow that runs on a schedule and on an event as well: trigger schedule, and fill in \"webhook\" too.\n webhook: sender (\"stripe\", \"github\", \"shopify\", \"standard\" for Standard Webhooks senders such as Clerk, Resend or Svix, else \"custom\" for the person's own app or any other service); a short name (\"Stripe: new disputes\"); setup: one sentence on what to pick in the sender's webhook settings; filter: which events start a run; mapping: each parameter from a dot path in the payload. What senders send:\n - Stripe: one endpoint can get many event types, so filter path \"type\", values the event names (charge.dispute.created, checkout.session.completed, invoice.payment_failed, customer.subscription.deleted). The object is at data.object: data.object.id, data.object.customer, data.object.amount, data.object.charge.\n - GitHub: the event type is chosen in GitHub's settings (Issues, Pull requests, Pushes), so say it in setup; filter path \"action\", values such as opened, closed, created. Fields: issue.number, issue.title, pull_request.number, repository.full_name, sender.login.\n - Shopify: one webhook per topic (orders/paid, orders/create, customers/create), chosen in Shopify, so say it in setup and use no filter. The body is the object itself: id, name (the order number, like #1001), email, total_price, financial_status.\n - standard: filter path \"type\" (user.created, email.bounced); fields under data (data.id, data.email_addresses).\n - custom: whatever the person's app sends; say in setup what to post (JSON with these fields) and use the paths you described. Set \"header\" when the person names the signature header.\n Every required parameter should come from the payload through mapping; parameters are the only way event details reach the run.\n6. Notify (scheduled and webhook workflows): whether each run's result is sent to the person through the agent's messaging app. true when the result is for them and no step already delivers it (\"tell me\", \"send me\", \"let me know\", a summary or findings that no step posts or emails). false when a step already sends it (posting to a channel, emailing a report): it would arrive twice. false when the work itself is the outcome (answering emails, filing tasks), where a message every run is noise; questions and approvals reach them either way. Say why in one short sentence.\n7. Parameters: if the job is about something that changes from run to run (an order, a ticket, an alert, a customer, a date range), declare it as a parameter: a short snake_case name, a one-line description, and required when a run makes no sense without it. Write $name wherever its value goes, in startMessage and in the skill (\"Check the refund request for order $order_number.\"). Apps and people fill them in when they start a run. A scheduled job that finds its own work (\"check the inbox every 10 minutes\", \"last week's sales\") needs none. Don't make fixed settings, such as the repository or the channel, parameters: write them into the skill.\n8. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.\n9. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings). Not for MCP servers: their keys are asked for on the plan.\n\nThen call save_plan once, with the whole plan. Don't ask the person questions: if something is unclear, plan the most reasonable version and note the assumption in gaps.";
129
155
  export declare function workflowPlannerTools(withMcp?: boolean): ToolSchema[];
130
156
  /** lowercase-hyphenated, 2–40 characters. */
131
157
  export declare function slugify(value: string, fallback?: string): string;
132
158
  /** A plan from the model's save_plan input, or undefined when it lacks a
133
159
  * name, steps, a skill or a new-agent prompt. Fills in what can be derived. */
134
160
  export declare function validateWorkflowPlan(raw: unknown): WorkflowPlan | undefined;
161
+ /** A plan's webhook, tidied: a known sender, a filter with values, and a
162
+ * mapping only for parameters the workflow has. Undefined without a
163
+ * sender. Also used on a plan edited before Create. */
164
+ export declare function planWebhook(raw: unknown, parameters: Record<string, WorkflowParameter>): PlannedWebhook | undefined;
165
+ /** What the person should know about a planned webhook: required
166
+ * parameters nothing in the payload fills. */
167
+ export declare function webhookNotes(webhook: PlannedWebhook, parameters: Record<string, WorkflowParameter>): string[];
135
168
  /** A plan's notify choice; off when the planner gave none. */
136
169
  export declare function planNotify(raw: unknown): WorkflowPlan['notify'];
137
170
  /** A plan's parameters, tidied: names made snake_case (or dropped), a
@@ -16,8 +16,19 @@ How to plan:
16
16
  - nothing found can do it: set "missing" to what's needed, and add it to gaps.
17
17
  3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.
18
18
  4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.
19
- 5. Trigger: a schedule ("every Monday at 9" is every "7d", at "09:00", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.
20
- 6. Notify (scheduled workflows): whether each run's result is sent to the person through the agent's messaging app. true when the result is for them and no step already delivers it ("tell me", "send me", "let me know", a summary or findings that no step posts or emails). false when a step already sends it (posting to a channel, emailing a report): it would arrive twice. false when the work itself is the outcome (answering emails, filing tasks), where a message every run is noise; questions and approvals reach them either way. Say why in one short sentence.
19
+ 5. Trigger: how a run starts. startMessage is what the agent receives when it starts.
20
+ - schedule: a time or interval ("every Monday at 9" is every "7d", at "09:00", weekday 1).
21
+ - webhook: when something happens in another service that can call a URL ("when a Stripe dispute comes in", "when a GitHub issue is opened", "when a Shopify order is paid", "when our app sends a new signup"). Fill in "webhook" (below). Prefer it over polling on a schedule whenever the service sends webhooks.
22
+ - on_request: started by a person, from Chat, a message, the Run button or an app's API call.
23
+ A workflow that runs on a schedule and on an event as well: trigger schedule, and fill in "webhook" too.
24
+ webhook: sender ("stripe", "github", "shopify", "standard" for Standard Webhooks senders such as Clerk, Resend or Svix, else "custom" for the person's own app or any other service); a short name ("Stripe: new disputes"); setup: one sentence on what to pick in the sender's webhook settings; filter: which events start a run; mapping: each parameter from a dot path in the payload. What senders send:
25
+ - Stripe: one endpoint can get many event types, so filter path "type", values the event names (charge.dispute.created, checkout.session.completed, invoice.payment_failed, customer.subscription.deleted). The object is at data.object: data.object.id, data.object.customer, data.object.amount, data.object.charge.
26
+ - GitHub: the event type is chosen in GitHub's settings (Issues, Pull requests, Pushes), so say it in setup; filter path "action", values such as opened, closed, created. Fields: issue.number, issue.title, pull_request.number, repository.full_name, sender.login.
27
+ - Shopify: one webhook per topic (orders/paid, orders/create, customers/create), chosen in Shopify, so say it in setup and use no filter. The body is the object itself: id, name (the order number, like #1001), email, total_price, financial_status.
28
+ - standard: filter path "type" (user.created, email.bounced); fields under data (data.id, data.email_addresses).
29
+ - custom: whatever the person's app sends; say in setup what to post (JSON with these fields) and use the paths you described. Set "header" when the person names the signature header.
30
+ Every required parameter should come from the payload through mapping; parameters are the only way event details reach the run.
31
+ 6. Notify (scheduled and webhook workflows): whether each run's result is sent to the person through the agent's messaging app. true when the result is for them and no step already delivers it ("tell me", "send me", "let me know", a summary or findings that no step posts or emails). false when a step already sends it (posting to a channel, emailing a report): it would arrive twice. false when the work itself is the outcome (answering emails, filing tasks), where a message every run is noise; questions and approvals reach them either way. Say why in one short sentence.
21
32
  7. Parameters: if the job is about something that changes from run to run (an order, a ticket, an alert, a customer, a date range), declare it as a parameter: a short snake_case name, a one-line description, and required when a run makes no sense without it. Write $name wherever its value goes, in startMessage and in the skill ("Check the refund request for order $order_number."). Apps and people fill them in when they start a run. A scheduled job that finds its own work ("check the inbox every 10 minutes", "last week's sales") needs none. Don't make fixed settings, such as the repository or the channel, parameters: write them into the skill.
22
33
  8. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.
23
34
  9. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings). Not for MCP servers: their keys are asked for on the plan.
@@ -59,7 +70,7 @@ export function workflowPlannerTools(withMcp = false) {
59
70
  trigger: {
60
71
  type: 'object',
61
72
  properties: {
62
- kind: { type: 'string', enum: ['schedule', 'on_request'] },
73
+ kind: { type: 'string', enum: ['schedule', 'webhook', 'on_request'] },
63
74
  description: { type: 'string' },
64
75
  every: { type: 'string' },
65
76
  at: { type: 'string' },
@@ -74,9 +85,22 @@ export function workflowPlannerTools(withMcp = false) {
74
85
  needsApprovals: { type: 'boolean' },
75
86
  skill: { type: 'object', properties: { description: { type: 'string' }, body: { type: 'string' } }, required: ['description', 'body'] },
76
87
  startMessage: { type: 'string' },
88
+ webhook: {
89
+ type: 'object',
90
+ description: 'When an outside service\'s events start runs: its sender, which events, and the payload fields that fill each parameter.',
91
+ properties: {
92
+ sender: { type: 'string', enum: ['stripe', 'github', 'shopify', 'standard', 'custom'] },
93
+ name: { type: 'string' },
94
+ header: { type: 'string', description: 'custom only: the signature header, when known' },
95
+ setup: { type: 'string', description: 'one sentence: what to pick in the sender\'s webhook settings' },
96
+ filter: { type: 'object', properties: { path: { type: 'string' }, values: { type: 'array', items: { type: 'string' } } }, required: ['path', 'values'] },
97
+ mapping: { type: 'object', description: 'parameter name -> dot path in the payload', additionalProperties: { type: 'string' } },
98
+ },
99
+ required: ['sender', 'name'],
100
+ },
77
101
  notify: {
78
102
  type: 'object',
79
- description: 'Scheduled workflows: whether each run\'s result goes to the person through the agent\'s messaging app, and why in one short sentence.',
103
+ description: 'Scheduled and webhook workflows: whether each run\'s result goes to the person through the agent\'s messaging app, and why in one short sentence.',
80
104
  properties: { on: { type: 'boolean' }, reason: { type: 'string' } },
81
105
  required: ['on', 'reason'],
82
106
  },
@@ -144,7 +168,9 @@ export function validateWorkflowPlan(raw) {
144
168
  ...(typeof triggerRaw.at === 'string' && /^\d{1,2}:\d{2}$/.test(triggerRaw.at) ? { at: triggerRaw.at } : {}),
145
169
  ...(typeof triggerRaw.weekday === 'number' && triggerRaw.weekday >= 0 && triggerRaw.weekday <= 7 ? { weekday: triggerRaw.weekday } : {}),
146
170
  }
147
- : { kind: 'on_request', description: str(triggerRaw.description, 200) ?? 'when asked' };
171
+ : triggerRaw.kind === 'webhook'
172
+ ? { kind: 'webhook', description: str(triggerRaw.description, 200) ?? 'when an event comes in' }
173
+ : { kind: 'on_request', description: str(triggerRaw.description, 200) ?? 'when asked' };
148
174
  // An MCP step's permissions are set when its server becomes an ability.
149
175
  const mcpTools = new Set(steps.flatMap((s) => (s.mcp && s.tool ? [s.tool] : [])));
150
176
  const rules = (Array.isArray(r.rules) ? r.rules : []).flatMap((x) => {
@@ -172,6 +198,15 @@ export function validateWorkflowPlan(raw) {
172
198
  const startMessage = str(r.startMessage, 1000) ?? `Run the "${name}" workflow.`;
173
199
  const gaps = Array.isArray(r.gaps) ? r.gaps.flatMap((g) => (str(g, 400) ? [str(g, 400)] : [])).slice(0, 10) : [];
174
200
  const planned = planParameters(r.parameters, { startMessage, skillBody, scheduled: trigger.kind === 'schedule' });
201
+ const webhook = planWebhook(r.webhook, planned.parameters);
202
+ // A webhook described means events start it; a webhook start with none
203
+ // described is started by hand.
204
+ if (webhook && trigger.kind === 'on_request')
205
+ trigger.kind = 'webhook';
206
+ if (!webhook && trigger.kind === 'webhook')
207
+ trigger.kind = 'on_request';
208
+ if (webhook)
209
+ planned.notes.push(...webhookNotes(webhook, planned.parameters));
175
210
  return {
176
211
  name,
177
212
  id,
@@ -185,6 +220,7 @@ export function validateWorkflowPlan(raw) {
185
220
  skill: { description: str(skillRaw.description, 400) ?? summary, body: skillBody },
186
221
  startMessage,
187
222
  parameters: planned.parameters,
223
+ ...(webhook ? { webhook } : {}),
188
224
  notify: planNotify(r.notify),
189
225
  target: {
190
226
  kind: targetRaw.kind === 'existing' && str(targetRaw.agent, 80) ? 'existing' : 'new',
@@ -195,6 +231,44 @@ export function validateWorkflowPlan(raw) {
195
231
  gaps: [...gaps, ...planned.notes].slice(0, 12),
196
232
  };
197
233
  }
234
+ const SENDERS = ['stripe', 'github', 'shopify', 'standard', 'custom'];
235
+ /** A plan's webhook, tidied: a known sender, a filter with values, and a
236
+ * mapping only for parameters the workflow has. Undefined without a
237
+ * sender. Also used on a plan edited before Create. */
238
+ export function planWebhook(raw, parameters) {
239
+ if (!raw || typeof raw !== 'object')
240
+ return undefined;
241
+ const w = raw;
242
+ if (!SENDERS.includes(w.sender))
243
+ return undefined;
244
+ const sender = w.sender;
245
+ const filterRaw = (w.filter ?? {});
246
+ const filterPath = str(filterRaw.path, 120);
247
+ const values = Array.isArray(filterRaw.values) ? filterRaw.values.flatMap((v) => (str(v, 120) ? [str(v, 120)] : [])).slice(0, 10) : [];
248
+ const mapping = {};
249
+ if (w.mapping && typeof w.mapping === 'object' && !Array.isArray(w.mapping)) {
250
+ for (const [param, path] of Object.entries(w.mapping)) {
251
+ const p = str(path, 200);
252
+ if (p && parameters[param] && /^[A-Za-z0-9_.-]+$/.test(p))
253
+ mapping[param] = p;
254
+ }
255
+ }
256
+ const header = sender === 'custom' ? str(w.header, 80) : undefined;
257
+ return {
258
+ sender,
259
+ name: str(w.name, 80) ?? `${sender === 'custom' ? 'App' : sender[0].toUpperCase() + sender.slice(1)} events`,
260
+ ...(header && /^[A-Za-z0-9-]{1,80}$/.test(header) ? { header } : {}),
261
+ ...(filterPath && values.length ? { filter: { path: filterPath, values } } : {}),
262
+ ...(Object.keys(mapping).length ? { mapping } : {}),
263
+ ...(str(w.setup, 300) ? { setup: str(w.setup, 300) } : {}),
264
+ };
265
+ }
266
+ /** What the person should know about a planned webhook: required
267
+ * parameters nothing in the payload fills. */
268
+ export function webhookNotes(webhook, parameters) {
269
+ const unfilled = Object.entries(parameters).filter(([name, p]) => p.required && !webhook.mapping?.[name]).map(([name]) => name);
270
+ return unfilled.length ? [`Nothing in the webhook's payload fills ${unfilled.join(', ')}, so those runs would fail; map ${unfilled.length > 1 ? 'them' : 'it'} on the workflow's Webhooks after it's created.`] : [];
271
+ }
198
272
  /** A plan's notify choice; off when the planner gave none. */
199
273
  export function planNotify(raw) {
200
274
  const n = raw && typeof raw === 'object' ? raw : {};
@@ -0,0 +1,131 @@
1
+ import type { CallbackDelivery } from './run-callbacks.js';
2
+ export type WorkflowRunStatus = 'running' | 'awaiting_approval' | 'done' | 'error';
3
+ export interface WorkflowRun {
4
+ run_id: string;
5
+ agent: string;
6
+ workflow_id: string;
7
+ params?: Record<string, string>;
8
+ /** Who started it: the console's Run now, an app over the API, a
9
+ * schedule, or an outside service through a webhook trigger. */
10
+ source: 'console' | 'api' | 'schedule' | 'webhook';
11
+ /** The webhook trigger that started it, for source "webhook"
12
+ * (core/workflow-triggers.ts). */
13
+ trigger_id?: string;
14
+ /** The schedule that started it, for source "schedule". */
15
+ task_id?: string;
16
+ task_name?: string;
17
+ status: WorkflowRunStatus;
18
+ started_at: string;
19
+ finished_at?: string;
20
+ /** The message it started with: the workflow's start message and steps. */
21
+ message: string;
22
+ session_id: string;
23
+ /** The agent's final reply, when done. */
24
+ result?: string;
25
+ error?: string;
26
+ /** The approvals or questions it is waiting on. */
27
+ pending_ids?: string[];
28
+ /** Send its result through the agent's notifier when it ends. */
29
+ notify?: boolean;
30
+ /** Where its record is posted when it ends or stops for a decision
31
+ * (core/run-callbacks.ts). */
32
+ callback_url?: string;
33
+ /** Each callback delivery, oldest first (the latest 10). */
34
+ callbacks?: CallbackDelivery[];
35
+ }
36
+ export type RunOutcome = {
37
+ kind: 'done';
38
+ text: string;
39
+ } | {
40
+ kind: 'pending';
41
+ pendingIds: string[];
42
+ };
43
+ /** Runs the agent once: what a scheduled task's fire does. */
44
+ export type ExecuteRun = (agent: string, message: string, sessionId: string) => Promise<RunOutcome>;
45
+ export declare class RunError extends Error {
46
+ }
47
+ export interface RunStore {
48
+ list(): Promise<WorkflowRun[]>;
49
+ get(runId: string): Promise<WorkflowRun | undefined>;
50
+ put(run: WorkflowRun): Promise<void>;
51
+ remove(runId: string): Promise<void>;
52
+ update(runId: string, fn: (run: WorkflowRun | undefined) => WorkflowRun | undefined): Promise<WorkflowRun | undefined>;
53
+ }
54
+ /** One JSON file per run under `<dir>/runs/` — one server process. */
55
+ export declare class FileRunStore implements RunStore {
56
+ private readonly dir;
57
+ constructor(dir: string);
58
+ private file;
59
+ list(): Promise<WorkflowRun[]>;
60
+ get(runId: string): Promise<WorkflowRun | undefined>;
61
+ put(run: WorkflowRun): Promise<void>;
62
+ remove(runId: string): Promise<void>;
63
+ update(runId: string, fn: (run: WorkflowRun | undefined) => WorkflowRun | undefined): Promise<WorkflowRun | undefined>;
64
+ }
65
+ /** The process-wide store: `runs/` in the scheduler's folder. */
66
+ export declare function getRunStore(): RunStore;
67
+ export declare function setRunStore(next: RunStore): void;
68
+ /** An agent's runs, newest first; of one workflow when `workflowId` is given. */
69
+ export declare function listRuns(agent: string, filter?: {
70
+ workflowId?: string;
71
+ limit?: number;
72
+ }): Promise<WorkflowRun[]>;
73
+ /** One of an agent's runs; undefined for another agent's. */
74
+ export declare function getRun(agent: string, runId: string): Promise<WorkflowRun | undefined>;
75
+ export declare function isUnderWay(run: WorkflowRun): boolean;
76
+ export interface StartRunInput {
77
+ agent: string;
78
+ workflow_id: string;
79
+ params?: Record<string, string>;
80
+ source: WorkflowRun['source'];
81
+ message: string;
82
+ notify?: boolean;
83
+ callback_url?: string;
84
+ trigger_id?: string;
85
+ }
86
+ export declare class WorkflowRunner {
87
+ private readonly execute;
88
+ private readonly maxFinishedPerAgent;
89
+ private readonly onChange?;
90
+ private readonly inFlight;
91
+ /** `onChange`: called with the run each time it finishes, fails or
92
+ * stops for a decision (the server sends its notify and callback). */
93
+ constructor(options: {
94
+ execute: ExecuteRun;
95
+ maxFinishedPerAgent?: number;
96
+ onChange?: (run: WorkflowRun) => void | Promise<void>;
97
+ });
98
+ private changed;
99
+ /** Records a run and starts it; returns at once with the record. */
100
+ start(input: StartRunInput): Promise<WorkflowRun>;
101
+ /** A schedule firing one of its agent's workflows (the scheduler's fire,
102
+ * for a workflow task): recorded as a run of the workflow, then run the
103
+ * usual way. Returns the outcome for the scheduler, and throws what the
104
+ * run threw, so the schedule's own history records it too. */
105
+ runForSchedule(input: {
106
+ agent: string;
107
+ workflow_id: string;
108
+ params?: Record<string, string>;
109
+ task_id: string;
110
+ task_name?: string;
111
+ message: string;
112
+ session_id: string;
113
+ }): Promise<RunOutcome>;
114
+ /** Waits for the runs this process has started (tests, shutdown). */
115
+ idle(): Promise<void>;
116
+ /** How many runs are executing right now (for idle-shutdown checks). */
117
+ get running(): number;
118
+ private run;
119
+ private finish;
120
+ /** A paused run resumed after a decision (the server calls this from
121
+ * its resolve path, for every resumed session): settle it, or record
122
+ * what it's waiting on now. */
123
+ settleFromResume(sessionId: string, outcome: {
124
+ text: string;
125
+ pendingIds?: string[];
126
+ }): Promise<void>;
127
+ /** On startup: a run a restart cut off can't continue, so it's an
128
+ * error. One waiting on a decision is kept: its checkpoint survives. */
129
+ recover(): Promise<void>;
130
+ private prune;
131
+ }