loopengine 0.3.4 → 0.3.6

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.
@@ -8,17 +8,26 @@ export interface Workflow {
8
8
  /** A name for people, e.g. "Refund check". */
9
9
  title: string;
10
10
  description: string;
11
+ /** A workflow saved before instructions: what its runs began with. Empty
12
+ * for one saved since. */
11
13
  startMessage: string;
12
- /** When it runs, in plain words: "Every Monday at 9", "When asked". */
14
+ /** A label from before, in words ("Every Monday at 9"); "When asked"
15
+ * when there's none. Nothing reads it to decide when to run. */
13
16
  trigger: string;
14
17
  parameters: Record<string, WorkflowParameter>;
15
- /** Its steps: the skill body, placeholders unfilled. */
18
+ /** The skill body, placeholders unfilled. */
16
19
  steps: string;
20
+ /** What the agent does, as one text: the body, with an older
21
+ * workflow's start message in front of it. What the editor shows. */
22
+ instructions: string;
17
23
  }
18
- /** The front matter that makes a skill a workflow. */
24
+ /** The front matter that makes a skill a workflow: `instructions` (the
25
+ * new way) replaces the body and drops any start message and label;
26
+ * `startMessage` (from before) is kept for workflows saved that way. */
19
27
  export interface WorkflowFields {
20
28
  title?: string;
21
- startMessage: string;
29
+ instructions?: string;
30
+ startMessage?: string;
22
31
  trigger?: string;
23
32
  parameters?: Record<string, WorkflowParameter>;
24
33
  }
@@ -1,25 +1,34 @@
1
1
  // An agent's workflows: skills it can be told to run, by hand, from an app
2
2
  // over the API, or on a schedule. A workflow is a skill
3
- // (agents/<name>/skills/<id>/SKILL.md) whose front matter also says how a
4
- // run starts:
3
+ // (agents/<name>/skills/<id>/SKILL.md) marked as one, with a title and the
4
+ // values a run is given:
5
5
  //
6
6
  // ---
7
7
  // name: refund-check
8
8
  // description: Check a refund request against the order and our policy
9
+ // workflow: true
9
10
  // title: Refund check
10
- // start_message: Check the refund request for order $order_number.
11
- // trigger: When a refund request comes in
12
11
  // parameters:
13
12
  // order_number: { description: The order to check, required: true }
14
13
  // customer_note: { description: What the customer wrote }
15
14
  // ---
15
+ // Check the refund request for order $order_number.
16
16
  // 1. Look up order $order_number…
17
17
  //
18
- // The body is its steps. A run starts with the start message and those
19
- // steps, with each $parameter filled in, so the model has them from its
20
- // first turn rather than picking the skill from its index. None of these
21
- // keys reach the model otherwise: the skill index shows only a skill's
22
- // name and description (core/skillgarden).
18
+ // The body is its instructions: one text, edited as one. A run starts with
19
+ // a line naming the workflow and those instructions, with each $parameter
20
+ // filled in, so the model has them from its first turn rather than
21
+ // picking the skill from its index. In chat, the agent loads the same
22
+ // body through the Skill tool. None of the workflow keys reach the model
23
+ // otherwise: the skill index shows only a skill's name and description
24
+ // (core/skillgarden).
25
+ //
26
+ // What starts a run (schedules, webhooks) isn't kept here: it's the
27
+ // workflow's schedules and webhook triggers themselves.
28
+ //
29
+ // Workflows saved before had a separate start_message (a skill with one
30
+ // is still a workflow) and a `trigger` label; both are folded into the
31
+ // instructions the next time the workflow is saved.
23
32
  //
24
33
  // Agents from before kept their workflows in workflows.yml;
25
34
  // migrateWorkflowsFile moves those into their skills.
@@ -30,7 +39,7 @@ import { parseSkillFile, substituteParameters } from './skillgarden/index.js';
30
39
  export class WorkflowParamsError extends Error {
31
40
  }
32
41
  const PARAMETER_NAME = /^[a-z_][a-z0-9_]*$/;
33
- const WORKFLOW_KEYS = ['title', 'start_message', 'trigger', 'parameters'];
42
+ const WORKFLOW_KEYS = ['workflow', 'title', 'start_message', 'trigger', 'parameters'];
34
43
  function skillsDir(agentDir) {
35
44
  return join(agentDir, 'skills');
36
45
  }
@@ -72,9 +81,19 @@ function parametersFrom(value) {
72
81
  }
73
82
  return parameters;
74
83
  }
84
+ /** An older workflow's start message and body as one text; the body
85
+ * alone when it already begins with the start message. */
86
+ function combineInstructions(startMessage, body) {
87
+ const steps = body.trim();
88
+ if (!startMessage)
89
+ return steps;
90
+ if (steps.startsWith(startMessage))
91
+ return steps;
92
+ return steps ? `${startMessage}\n\n${steps}` : startMessage;
93
+ }
75
94
  function workflowFrom(id, frontmatter, body) {
76
95
  const startMessage = typeof frontmatter.start_message === 'string' ? frontmatter.start_message.trim() : '';
77
- if (!startMessage)
96
+ if (frontmatter.workflow !== true && !startMessage)
78
97
  return undefined;
79
98
  return {
80
99
  id,
@@ -84,6 +103,7 @@ function workflowFrom(id, frontmatter, body) {
84
103
  trigger: typeof frontmatter.trigger === 'string' && frontmatter.trigger.trim() ? frontmatter.trigger.trim() : 'When asked',
85
104
  parameters: parametersFrom(frontmatter.parameters),
86
105
  steps: body,
106
+ instructions: combineInstructions(startMessage, body),
87
107
  };
88
108
  }
89
109
  /** The agent's workflows: its skills that have a start message. */
@@ -121,12 +141,19 @@ export function setWorkflowFields(agentDir, id, fields) {
121
141
  const frontmatter = { ...skill.frontmatter };
122
142
  for (const key of WORKFLOW_KEYS)
123
143
  delete frontmatter[key];
144
+ const parameters = fields.parameters && Object.keys(fields.parameters).length ? { parameters: fields.parameters } : {};
145
+ if (fields.instructions !== undefined) {
146
+ writeSkillFile(path, { ...frontmatter, workflow: true, ...(fields.title ? { title: fields.title } : {}), ...parameters }, fields.instructions);
147
+ return;
148
+ }
149
+ if (!fields.startMessage)
150
+ throw new Error('A workflow needs its instructions.');
124
151
  writeSkillFile(path, {
125
152
  ...frontmatter,
126
153
  ...(fields.title ? { title: fields.title } : {}),
127
154
  start_message: fields.startMessage,
128
155
  ...(fields.trigger ? { trigger: fields.trigger } : {}),
129
- ...(fields.parameters && Object.keys(fields.parameters).length ? { parameters: fields.parameters } : {}),
156
+ ...parameters,
130
157
  }, skill.body);
131
158
  }
132
159
  /** Stops a skill being a workflow. The skill itself stays. */
@@ -182,13 +209,15 @@ export function workflowRunMessage(workflow, params = {}) {
182
209
  // Named placeholders only: positional $1-style filling would eat dollar
183
210
  // amounts like "$50" in the steps.
184
211
  const fill = (text) => substituteParameters(text, params, { declared, used });
185
- const start = fill(workflow.startMessage);
212
+ // An older workflow begins with its own start message; one saved since
213
+ // with a line naming it, its instructions saying the rest.
214
+ const start = workflow.startMessage ? fill(workflow.startMessage) : `Run the "${workflow.title}" workflow.`;
186
215
  const steps = fill(workflow.steps).trim();
187
216
  const unused = Object.entries(params).filter(([name]) => !used.has(name));
188
217
  return [
189
218
  start,
190
219
  unused.length ? `Inputs:\n${unused.map(([name, value]) => `- ${name}: ${value}`).join('\n')}` : '',
191
- steps ? `These are the steps, from your "${workflow.id}" skill:\n\n${steps}` : '',
220
+ steps ? `${workflow.startMessage ? 'These are the steps' : 'These are its instructions'}, from your "${workflow.id}" skill:\n\n${steps}` : '',
192
221
  ].filter(Boolean).join('\n\n');
193
222
  }
194
223
  export function legacyWorkflowsPath(agentDir) {
@@ -49,6 +49,18 @@ export const agentsConfigPageHtml = `<!doctype html>
49
49
  form.add-source .schedule-when input[type="number"] { width: 80px; }
50
50
  form.add-source .schedule-when select, form.add-source .schedule-when input[type="datetime-local"] { width: auto; }
51
51
  .workflow-card { margin-bottom: 10px; }
52
+ .workflow-starts { margin: 10px 0 0; padding: 10px 12px; border: 1px solid var(--le-line, #e5e7eb); border-radius: 8px; background: var(--le-surface, #f6f7f9); }
53
+ .workflow-starts h4 { margin: 0; font-size: 13.5px; font-weight: 600; }
54
+ .wf-start { padding: 10px 0; border-top: 1px solid var(--le-line, #e5e7eb); display: grid; gap: 6px; font-size: 13px; }
55
+ .wf-start:first-of-type { border-top: 0; padding-top: 6px; }
56
+ .wf-start-head { display: flex; align-items: center; gap: 6px; }
57
+ .wf-start-head strong { font-size: 13px; }
58
+ .help-q { width: 18px; height: 18px; padding: 0; border-radius: 50%; font-size: 11px; line-height: 16px; font-weight: 600; text-align: center; cursor: pointer; }
59
+ .help-text { margin: 0; }
60
+ .help-text[hidden] { display: none; }
61
+ .wf-start .schedule-history { margin: 0; gap: 6px; }
62
+ .wf-start .workflow-params { margin: 0; }
63
+ .wf-start pre { margin: 0; padding: 8px 10px; border-radius: 6px; background: var(--le-bg, #fff); border: 1px solid var(--le-line, #e5e7eb); font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
52
64
  .workflow-steps-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; margin: 6px 0 0; padding: 8px 10px; border-radius: 6px; background: var(--le-surface, #f6f7f9); }
53
65
  .workflow-params { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
54
66
  .workflow-params[hidden] { display: none; }
@@ -667,8 +679,9 @@ ${consoleShellScript}
667
679
  (cfg.abilityCount != null
668
680
  ? card('abilities', 'Apps', String(cfg.abilityCount), cfg.abilityCount ? 'Apps and abilities added to this agent' : 'Add apps and abilities, with the tools you choose')
669
681
  : card('abilities', 'Apps', 'Browse', 'Add apps and abilities, with the tools you choose')) +
670
- card('tools', 'Tools', String(cfg.tools.length), escapeHtml(toolNames.slice(0, 3).join(', ') + (toolNames.length > 3 ? ', \u2026' : '')) || 'None yet') +
671
- card('skills', 'Skills', String(cfg.skills.length), escapeHtml(cfg.skills.map(function (k) { return k.name; }).join(', ')) || 'None yet') +
682
+ card('workflows', 'Workflows', '<span id="ovWorkflowsValue">&hellip;</span>', '<span id="ovWorkflowsDetail">Loading&hellip;</span>') +
683
+ card('tools', 'Tools &amp; skills', cfg.tools.length + (cfg.tools.length === 1 ? ' tool' : ' tools'),
684
+ (cfg.skills.length + (cfg.skills.length === 1 ? ' skill' : ' skills')) + (toolNames.length ? ' \u00b7 ' + escapeHtml(toolNames.slice(0, 2).join(', ') + (toolNames.length > 2 ? ', \u2026' : '')) : '')) +
672
685
  card('actauth', 'Permissions', rules.length + (rules.length === 1 ? ' rule' : ' rules'),
673
686
  (asks ? asks + ' need approval \u00b7 ' : '') + 'unmatched calls: ' + escapeHtml(cfg.permissions.defaultDecision)) +
674
687
  card('messaging', 'Messaging', '<span id="ovMessagingValue">&hellip;</span>', '<span id="ovMessagingDetail">Loading&hellip;</span>') +
@@ -683,8 +696,69 @@ ${consoleShellScript}
683
696
  // cards are filled in once those answer. Called after every render of
684
697
  // Overview, which is also re-rendered after edits.
685
698
  // ---- Overview: the agent's workflows (web/workflow-admin.ts), added
686
- // from "Describe a workflow": each with its trigger, its schedule's
687
- // state, its steps (checkpoints marked) and Run now. ----
699
+ // from "Describe a workflow": each with its instructions, what starts it
700
+ // (its real schedules and webhooks), its recent runs and Run now. ----
701
+
702
+ // What starts a workflow, in a few words, from its schedules and
703
+ // webhooks: never a label that could say something else.
704
+ function workflowStartsSummary(w) {
705
+ var parts = (w.schedules || []).filter(function (x) { return x.repeating && x.status !== 'paused'; }).map(function (x) { return x.describe; });
706
+ if (w.webhooks) parts.push(w.webhooks === 1 ? 'when its webhook is called' : 'when one of its ' + w.webhooks + ' webhooks is called');
707
+ if (!parts.length) return 'No schedule or webhook yet';
708
+ var text = parts.join(', and ');
709
+ return text.charAt(0).toUpperCase() + text.slice(1);
710
+ }
711
+
712
+ // How it starts: four ways, each with a ? that says what it is. A
713
+ // schedule (pause, resume, remove, add), a webhook, Run now with the
714
+ // values a run is given, and the API call an app makes.
715
+ function wfStartSection(key, title, help, body) {
716
+ return '<div class="wf-start">' +
717
+ '<div class="wf-start-head"><strong>' + title + '</strong>' +
718
+ '<button type="button" class="help-q" aria-expanded="false" aria-controls="help-' + key + '" aria-label="What is ' + title.toLowerCase() + '?">?</button></div>' +
719
+ '<p class="hint help-text" hidden>' + help + '</p>' + body + '</div>';
720
+ }
721
+
722
+ function workflowStartsHtml(w, agentName, paramsHtml) {
723
+ var schedules = (w.schedules || []).map(function (x) {
724
+ var state = x.waiting ? '<span class="le-pill ask">Waiting for a decision</span>'
725
+ : x.status === 'paused' ? '<span class="le-pill">Paused</span>'
726
+ : x.status === 'running' ? '<span class="le-pill accent">Running</span>'
727
+ : (x.nextRunAt ? '<span class="hint">next ' + escapeHtml(new Date(x.nextRunAt).toLocaleString()) + '</span>' : '');
728
+ var values = x.params ? Object.keys(x.params).map(function (k) { return escapeHtml(k) + ': ' + escapeHtml(x.params[k]); }).join(' ' + String.fromCharCode(183) + ' ') : '';
729
+ return '<li style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
730
+ '<span>' + escapeHtml(x.describe.charAt(0).toUpperCase() + x.describe.slice(1)) + (x.name ? ' <span class="hint">(' + escapeHtml(x.name) + ')</span>' : '') + '</span>' + state +
731
+ (values ? '<span class="hint">' + values + '</span>' : '') +
732
+ (x.notify ? '<span class="hint">sends you each result</span>' : '') +
733
+ '<span style="display:flex;gap:6px;margin-left:auto">' +
734
+ (x.status === 'paused' ? '<button type="button" class="workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="resume">Resume</button>'
735
+ : x.status === 'scheduled' ? '<button type="button" class="workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="pause">Pause</button>' : '') +
736
+ '<button type="button" class="delete-btn workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="cancel">Remove</button>' +
737
+ '</span></li>';
738
+ }).join('');
739
+ var paramNames = Object.keys(w.parameters || {});
740
+ var example = paramNames.length
741
+ ? '{ "params": { ' + paramNames.map(function (p) { return '"' + p + '": "' + String.fromCharCode(8230) + '"'; }).join(', ') + ' } }'
742
+ : '{}';
743
+ var apiUrl = location.origin + '/agents/' + encodeURIComponent(agentName) + '/workflows/' + encodeURIComponent(w.id) + '/run';
744
+ return '<div class="workflow-starts"><h4>How it starts</h4>' +
745
+ wfStartSection('schedule-' + w.id, 'Schedule',
746
+ 'Runs it on a timer, like every day at 9:00, with the values you give the schedule. Nobody needs to be there: approvals and questions go to the agent' + String.fromCharCode(8217) + 's messaging app.',
747
+ (schedules ? '<ul class="schedule-history">' + schedules + '</ul>' : '<span class="hint">None yet.</span>') +
748
+ '<div><button type="button" class="workflow-add-schedule">Add a schedule</button></div>') +
749
+ wfStartSection('webhook-' + w.id, 'Webhook',
750
+ 'A private web address another app (Stripe, GitHub, Shopify or your own) calls when something happens. Each call starts a run, with values taken from what the app sends.',
751
+ '<span' + (w.webhooks ? '' : ' class="hint"') + '>' + (w.webhooks ? (w.webhooks === 1 ? '1 webhook is on.' : w.webhooks + ' webhooks are on.') : 'None yet.') + '</span>' +
752
+ '<div><button type="button" class="workflow-hooks-btn">' + (w.webhooks ? 'Webhooks' : 'Add a webhook') + '</button></div>') +
753
+ wfStartSection('run-' + w.id, 'Run now',
754
+ 'Starts a run right away, from here' + (paramNames.length ? ', with the values you fill in' : '') + '. It shows under Recent runs as it goes.',
755
+ (paramsHtml ? '<div class="workflow-params">' + paramsHtml + '</div>' : '') +
756
+ '<div><button type="button" class="workflow-run primary">Run now</button></div>') +
757
+ wfStartSection('api-' + w.id, 'API',
758
+ 'Your own app starts a run by calling this address with an API key. Add "wait": true to get the result in the answer, or "callback_url" to have it posted to you when the run ends.',
759
+ '<pre>POST ' + escapeHtml(apiUrl) + String.fromCharCode(10) + escapeHtml(example) + '</pre>') +
760
+ '</div>';
761
+ }
688
762
 
689
763
  function renderWorkflowsHtml(name, workflows) {
690
764
  if (!workflows.length) {
@@ -693,7 +767,7 @@ ${consoleShellScript}
693
767
  return workflows.map(function (w) {
694
768
  var s = w.schedule;
695
769
  var state = !s
696
- ? '<span class="hint">' + escapeHtml(w.trigger) + '</span>'
770
+ ? '<span class="hint">' + escapeHtml(workflowStartsSummary(w)) + '</span>'
697
771
  : s.waiting
698
772
  ? '<span class="le-pill ask">Waiting for a decision</span>'
699
773
  : s.status === 'paused'
@@ -707,7 +781,6 @@ ${consoleShellScript}
707
781
  return '<label class="workflow-param">' + escapeHtml(p) + (spec.required ? '' : ' <span class="hint">(optional)</span>') +
708
782
  '<input name="' + escapeHtml(p) + '"' + (spec.required ? ' required' : '') + ' placeholder="' + escapeHtml(spec.description || '') + '"></label>';
709
783
  }).join('');
710
- var scheduleCount = (w.schedules || []).filter(function (x) { return x.repeating; }).length;
711
784
  var runs = (w.recentRuns || []).map(function (r) {
712
785
  var pill = r.status === 'done' ? '<span class="le-pill good">Done</span>'
713
786
  : r.status === 'error' ? '<span class="le-pill bad">Failed</span>'
@@ -722,22 +795,44 @@ ${consoleShellScript}
722
795
  return '<div class="source workflow-card" data-workflow="' + escapeHtml(w.id) + '">' +
723
796
  '<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:baseline">' +
724
797
  '<strong>' + escapeHtml(w.title) + '</strong><span>' + state + '</span></div>' +
725
- '<p class="hint" style="margin:4px 0 6px">' + escapeHtml(w.description) + (s ? ' ' + String.fromCharCode(183) + ' ' + escapeHtml(w.trigger) : '') + '</p>' +
726
- '<details><summary class="hint">Steps <span>(the ' + escapeHtml(w.id) + ' skill)</span></summary><div class="workflow-steps-text">' + escapeHtml(w.steps) + '</div></details>' +
727
- (params ? '<div class="workflow-params">' + params + '</div>' : '') +
728
- (runs ? '<details class="workflow-runs"' + ((w.recentRuns || []).some(function (r) { return r.status === 'running' || r.status === 'awaiting_approval'; }) ? ' open' : '') + '><summary class="hint">Recent runs (' + w.recentRuns.length + ')</summary><ul class="schedule-history">' + runs + '</ul></details>' : '') +
729
- '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">' +
730
- '<button type="button" class="workflow-run">Run now</button>' +
731
- (scheduleCount ? '<a href="?tab=schedules" data-tab="schedules" class="hint" style="align-self:center">' + (scheduleCount > 1 ? 'Schedules (' + scheduleCount + ')' : 'Schedule') + '</a>' : '') +
798
+ '<p class="hint" style="margin:4px 0 6px">' + escapeHtml(w.description) + (s ? ' ' + String.fromCharCode(183) + ' ' + escapeHtml(workflowStartsSummary(w)) : '') + '</p>' +
799
+ '<details open><summary class="hint">Instructions <span>(the ' + escapeHtml(w.id) + ' skill)</span></summary><div class="workflow-steps-text">' + escapeHtml(w.instructions) + '</div></details>' +
800
+ workflowStartsHtml(w, name, params) +
801
+ '<div class="hint workflow-result" style="margin-top:6px"></div><div class="workflow-hooks" hidden></div>' +
802
+ (runs ? '<details class="workflow-runs" style="margin-top:10px"' + ((w.recentRuns || []).some(function (r) { return r.status === 'running' || r.status === 'awaiting_approval'; }) ? ' open' : '') + '><summary class="hint">Recent runs (' + w.recentRuns.length + ')</summary><ul class="schedule-history">' + runs + '</ul></details>'
803
+ : '<p class="hint" style="margin:10px 0 0">No runs yet.</p>') +
804
+ '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">' +
732
805
  '<button type="button" class="workflow-edit">Edit</button>' +
733
- '<button type="button" class="workflow-hooks-btn">Webhooks</button>' +
734
- '<a href="?tab=skills" data-tab="skills" class="hint" style="align-self:center">Edit steps</a>' +
735
- '<button type="button" class="delete-btn workflow-forget">Forget</button>' +
736
- '</div><div class="hint workflow-result"></div><div class="workflow-editor" hidden></div><div class="workflow-hooks" hidden></div></div>';
806
+ '<button type="button" class="delete-btn workflow-forget">Remove workflow</button>' +
807
+ '</div><div class="workflow-editor" hidden></div></div>';
737
808
  }).join('') + '<p class="hint" style="margin:6px 0 0"><a href="/workflows/new">Describe another workflow</a></p>';
738
809
  }
739
810
 
811
+ // The Overview's Workflows tile: how many, the next scheduled run, and
812
+ // any waiting for a decision.
813
+ function fillWorkflowsCard(workflows) {
814
+ var value = detail.querySelector('#ovWorkflowsValue');
815
+ var detailEl = detail.querySelector('#ovWorkflowsDetail');
816
+ if (!value || !detailEl) return;
817
+ value.textContent = String(workflows.length);
818
+ var next = null;
819
+ var waiting = 0;
820
+ workflows.forEach(function (w) {
821
+ (w.schedules || []).forEach(function (x) {
822
+ if (x.waiting) waiting++;
823
+ if (x.status === 'scheduled' && x.nextRunAt && (!next || x.nextRunAt < next)) next = x.nextRunAt;
824
+ });
825
+ (w.recentRuns || []).forEach(function (r) { if (r.status === 'awaiting_approval') waiting++; });
826
+ });
827
+ var parts = [];
828
+ if (waiting) parts.push(waiting + ' waiting for a decision');
829
+ if (next) parts.push('next run ' + new Date(next).toLocaleString());
830
+ detailEl.textContent = parts.join(' \u00b7 ') || (workflows.length ? 'Run by hand, by an app or a webhook' : 'Describe a job for this agent to do');
831
+ }
832
+
740
833
  var workflowsRefreshTimer = null;
834
+ // Set by a workflow's "Add a schedule", for the Schedules tab's form.
835
+ var pendingScheduleWorkflow = null;
741
836
  var workflowsById = {};
742
837
  // Set while a workflow's editor is open, so the running-run refresh
743
838
  // doesn't redraw the card under someone typing.
@@ -751,6 +846,7 @@ ${consoleShellScript}
751
846
  if (currentName !== name) return;
752
847
  if (!r.ok) { el.innerHTML = '<p class="error">Could not load workflows: ' + escapeHtml(r.body.error || 'unknown error') + '</p>'; return; }
753
848
  var workflows = r.body.workflows || [];
849
+ fillWorkflowsCard(workflows);
754
850
  workflowsById = {};
755
851
  workflows.forEach(function (w) { workflowsById[w.id] = w; });
756
852
  el.innerHTML = renderWorkflowsHtml(name, workflows);
@@ -758,12 +854,12 @@ ${consoleShellScript}
758
854
  for (var i = 0; i < cards.length; i++) wireWorkflowCard(name, cards[i]);
759
855
  // Keep a running run's status current while the Overview is open.
760
856
  var running = workflows.some(function (w) { return (w.recentRuns || []).some(function (x) { return x.status === 'running'; }); });
761
- if (running && currentTab === 'overview') workflowsRefreshTimer = setTimeout(function () { if (!workflowEditing) loadWorkflowsSection(name); }, 3000);
857
+ if (running && currentTab === 'workflows') workflowsRefreshTimer = setTimeout(function () { if (!workflowEditing) loadWorkflowsSection(name); }, 3000);
762
858
  });
763
859
  }
764
860
 
765
- // ---- The workflow editor: how a workflow's runs start. Its steps are
766
- // the skill body, edited on the Skills tab.
861
+ // ---- The workflow editor: its title, its instructions (the skill body)
862
+ // and the values a run is given. What starts it is on the card.
767
863
  function workflowParamRowHtml(pname, spec) {
768
864
  return '<div class="workflow-param-row">' +
769
865
  '<input class="wp-name" aria-label="Parameter name" placeholder="order_number" value="' + escapeHtml(pname || '') + '">' +
@@ -776,9 +872,8 @@ ${consoleShellScript}
776
872
  var rows = Object.keys(w.parameters || {}).map(function (p) { return workflowParamRowHtml(p, w.parameters[p]); }).join('');
777
873
  return '<form class="add-source workflow-editor-form" style="margin-top:10px">' +
778
874
  '<label>Title<input name="title" maxlength="120" value="' + escapeHtml(w.title) + '"></label>' +
779
- '<label>Start message <span class="hint">(what each run begins with; $name puts in a parameter&#39;s value)</span>' +
780
- '<textarea name="start_message" maxlength="4000" required>' + escapeHtml(w.startMessage) + '</textarea></label>' +
781
- '<label>When it runs <span class="hint">(in words, for people; the timing itself is its schedule)</span><input name="trigger" maxlength="200" value="' + escapeHtml(w.trigger) + '"></label>' +
875
+ '<label>Instructions <span class="hint">(what the agent does, step by step; $name puts in a parameter&#39;s value. Chat uses them too.)</span>' +
876
+ '<textarea name="instructions" maxlength="12000" rows="12" required>' + escapeHtml(w.instructions) + '</textarea></label>' +
782
877
  '<div class="body-field">Parameters <span class="hint">(values a run is given; apps send them as params)</span>' +
783
878
  '<div class="workflow-param-rows">' + rows + '</div>' +
784
879
  '<button type="button" class="wp-add" style="align-self:flex-start">Add parameter</button></div>' +
@@ -787,12 +882,12 @@ ${consoleShellScript}
787
882
  '<div class="hint workflow-editor-result"></div></form>';
788
883
  }
789
884
 
790
- // Placeholders used in the start message or steps that no parameter
791
- // declares, and declared parameters nothing uses.
885
+ // Placeholders the instructions use that no parameter declares, and
886
+ // declared parameters nothing uses.
792
887
  function workflowPlaceholderCheck(w, form) {
793
888
  var used = {};
794
889
  var pattern = new RegExp('[$]([a-z_][a-z0-9_]*)', 'g');
795
- var text = form.querySelector('[name="start_message"]').value + ' ' + (w.steps || '');
890
+ var text = form.querySelector('[name="instructions"]').value;
796
891
  var m;
797
892
  while ((m = pattern.exec(text))) used[m[1]] = true;
798
893
  var declared = {};
@@ -802,7 +897,7 @@ ${consoleShellScript}
802
897
  var missing = Object.keys(used).filter(function (n) { return !declared[n]; });
803
898
  var unused = Object.keys(declared).filter(function (n) { return !used[n]; });
804
899
  if (missing.length) notes.push('Uses ' + missing.map(function (n) { return '$' + n; }).join(', ') + ', which ' + (missing.length > 1 ? 'aren' : 'isn') + String.fromCharCode(8217) + 't a parameter: add ' + (missing.length > 1 ? 'them' : 'it') + ', or the text stays as written.');
805
- if (unused.length) notes.push(unused.join(', ') + ' ' + (unused.length > 1 ? 'aren' : 'isn') + String.fromCharCode(8217) + 't used in the start message or steps: its value is passed as an input.');
900
+ if (unused.length) notes.push(unused.join(', ') + ' ' + (unused.length > 1 ? 'aren' : 'isn') + String.fromCharCode(8217) + 't used in the instructions: its value is passed as an input.');
806
901
  form.querySelector('.workflow-editor-check').textContent = notes.join(' ');
807
902
  }
808
903
 
@@ -843,7 +938,7 @@ ${consoleShellScript}
843
938
  result.textContent = 'Saving' + String.fromCharCode(8230);
844
939
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(w.id), {
845
940
  method: 'PUT', headers: { 'content-type': 'application/json' },
846
- body: JSON.stringify({ title: form.title.value, start_message: form.start_message.value, trigger: form.trigger.value, parameters: parameters }),
941
+ body: JSON.stringify({ title: form.title.value, instructions: form.instructions.value, parameters: parameters }),
847
942
  }).then(function (r) {
848
943
  if (!r.ok) { result.textContent = r.body.error || 'Could not save'; return; }
849
944
  workflowEditing = false;
@@ -1039,7 +1134,32 @@ ${consoleShellScript}
1039
1134
  var result = card.querySelector('.workflow-result');
1040
1135
  var base = '/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(id);
1041
1136
  card.querySelector('.workflow-edit').addEventListener('click', function () { openWorkflowEditor(name, card); });
1137
+ // The ? beside each way it starts shows or hides what it is.
1138
+ var helps = card.querySelectorAll('.help-q');
1139
+ for (var hq = 0; hq < helps.length; hq++) {
1140
+ helps[hq].addEventListener('click', function (ev) {
1141
+ var btn = ev.currentTarget;
1142
+ var text = btn.closest('.wf-start').querySelector('.help-text');
1143
+ text.hidden = !text.hidden;
1144
+ btn.setAttribute('aria-expanded', String(!text.hidden));
1145
+ });
1146
+ }
1042
1147
  card.querySelector('.workflow-hooks-btn').addEventListener('click', function () { openWorkflowHooks(name, card); });
1148
+ // Add a schedule: the Schedules tab's form, with this workflow chosen.
1149
+ card.querySelector('.workflow-add-schedule').addEventListener('click', function () {
1150
+ pendingScheduleWorkflow = id;
1151
+ switchTab('schedules');
1152
+ });
1153
+ var scheduleBtns = card.querySelectorAll('.workflow-schedule-action');
1154
+ for (var sb = 0; sb < scheduleBtns.length; sb++) {
1155
+ scheduleBtns[sb].addEventListener('click', function (ev) {
1156
+ var btn = ev.currentTarget;
1157
+ var action = btn.dataset.action;
1158
+ if (action === 'cancel' && btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again to remove'; return; }
1159
+ btn.disabled = true;
1160
+ fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' }).then(function () { loadWorkflowsSection(name); });
1161
+ });
1162
+ }
1043
1163
  card.querySelector('.workflow-run').addEventListener('click', function (ev) {
1044
1164
  var btn = ev.currentTarget;
1045
1165
  var params = {};
@@ -1060,7 +1180,7 @@ ${consoleShellScript}
1060
1180
  });
1061
1181
  card.querySelector('.workflow-forget').addEventListener('click', function (ev) {
1062
1182
  var btn = ev.currentTarget;
1063
- if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again (its schedules are cancelled; the skill stays)'; return; }
1183
+ if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again: its schedules and webhooks go; its instructions stay as a skill for Chat'; return; }
1064
1184
  fetchJsonRetryOnce(base, { method: 'DELETE' }).then(function () { loadWorkflowsSection(name); });
1065
1185
  });
1066
1186
  }
@@ -1153,7 +1273,6 @@ ${consoleShellScript}
1153
1273
  }
1154
1274
 
1155
1275
  return renderOverviewCards(cfg) +
1156
- '<section id="workflowsSection"><h3>Workflows</h3><div id="workflowsContent"><p class="hint">Loading&hellip;</p></div></section>' +
1157
1276
  '<section>' + systemPromptSection + '</section>' +
1158
1277
  '<section>' + modelSection + '</section>' +
1159
1278
  '<section><h3>Hooks</h3><dl class="kv">' +
@@ -2251,7 +2370,7 @@ ${consoleShellScript}
2251
2370
  var list = tasks.length
2252
2371
  ? '<div class="le-table-wrap"><table><thead><tr><th>Task</th><th>When</th><th>Status</th><th>Last run</th><th></th></tr></thead><tbody>' + tasks.map(renderScheduleRow).join('') + '</tbody></table></div>'
2253
2372
  : '<p class="hint">Nothing scheduled yet.</p>';
2254
- return '<p class="muted" style="margin:0 0 20px">Workflows and messages this agent runs on a timer, like a cron job. A workflow schedule starts with the workflow&#39;s start message as it is at each run. Each run works like an app calling it over HTTP: an approval goes wherever the Messaging tab sends it, and with nowhere set, a call that needs approval is denied. The agent can also schedule its own tasks when asked in Chat (&ldquo;remind me tomorrow at 9&rdquo;); they appear here too.</p>' +
2373
+ return '<p class="muted" style="margin:0 0 20px">Workflows and messages this agent runs on a timer, like a cron job. A workflow schedule runs the workflow&#39;s instructions as they are at each run. Each run works like an app calling it over HTTP: an approval goes wherever the Messaging tab sends it, and with nowhere set, a call that needs approval is denied. The agent can also schedule its own tasks when asked in Chat (&ldquo;remind me tomorrow at 9&rdquo;); they appear here too.</p>' +
2255
2374
  '<section><h3>Scheduled (' + active.length + ' active)</h3>' + list + '</section>' +
2256
2375
  '<section><h3>New schedule</h3>' +
2257
2376
  '<form class="add-source" id="scheduleForm" style="max-width:560px">' +
@@ -2353,6 +2472,14 @@ ${consoleShellScript}
2353
2472
  '<input name="param:' + escapeHtml(p) + '" placeholder="' + escapeHtml(parameters[p].description || '') + '"></label>';
2354
2473
  }).join('');
2355
2474
  }
2475
+ // Opened from a workflow's "Add a schedule": that workflow, chosen.
2476
+ if (pendingScheduleWorkflow && workflowSelect) {
2477
+ workflowSelect.value = pendingScheduleWorkflow;
2478
+ var runsWorkflowRadio = form.querySelector('#runsWorkflow');
2479
+ if (runsWorkflowRadio) runsWorkflowRadio.checked = true;
2480
+ form.scrollIntoView({ block: 'start' });
2481
+ }
2482
+ pendingScheduleWorkflow = null;
2356
2483
  renderScheduleParams();
2357
2484
  if (workflowSelect) workflowSelect.addEventListener('change', renderScheduleParams);
2358
2485
  for (var k = 0; k < runsRadios.length; k++) {
@@ -4147,7 +4274,7 @@ ${consoleShellScript}
4147
4274
 
4148
4275
  // ---- Tab switching + agent selection ----
4149
4276
 
4150
- var CONFIG_TABS = ['overview', 'abilities', 'actauth', 'messaging', 'schedules', 'env', 'advanced'];
4277
+ var CONFIG_TABS = ['overview', 'workflows', 'abilities', 'actauth', 'messaging', 'schedules', 'env', 'advanced'];
4151
4278
  // The old Tools and Skills tabs: now parts of Advanced. A link to one
4152
4279
  // opens Advanced at that part.
4153
4280
  // The Apps tab keeps the id 'abilities'; ?tab=apps opens it too.
@@ -4284,6 +4411,11 @@ ${consoleShellScript}
4284
4411
  }) +
4285
4412
  '<div class="le-scroll"><div class="le-page" style="display:block">' +
4286
4413
  '<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
4414
+ // Workflows: the jobs this agent does, each with what starts it.
4415
+ '<div class="tab-panel" data-tab-panel="workflows">' +
4416
+ '<p class="muted" style="margin:0 0 20px">The jobs this agent does on its own: what it does, what starts it (schedules, webhooks, Run now or an app&#39;s API call) and how its runs went. In Chat it can do them too, when you ask.</p>' +
4417
+ '<section id="workflowsSection"><div id="workflowsContent"><p class="hint">Loading&hellip;</p></div></section>' +
4418
+ '</div>' +
4287
4419
  // Advanced: the agent's own tools (and Composio, subagents), then its
4288
4420
  // own skills. Each part keeps its old data-tab-panel name, which the
4289
4421
  // code that redraws it looks for; only .tab-panel is shown or hidden.
@@ -56,8 +56,8 @@ export const consoleShellScript = `<script>
56
56
  // ability on the Apps tab (id 'abilities', so older links still work).
57
57
  // ?tab=tools and ?tab=skills still open Advanced.
58
58
  var TABS = [
59
- ['overview', 'Overview'], ['chat', 'Chat'], ['abilities', 'Apps'], ['actauth', 'Permissions'],
60
- ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['env', 'Environment'], ['advanced', 'Advanced']
59
+ ['overview', 'Overview'], ['workflows', 'Workflows'], ['abilities', 'Apps'], ['actauth', 'Permissions'],
60
+ ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['env', 'Environment'], ['advanced', 'Advanced'], ['chat', 'Chat']
61
61
  ];
62
62
 
63
63
  function esc(s) {
@@ -72,11 +72,13 @@ export const consoleShellScript = `<script>
72
72
  return '/agents/config?agent=' + enc(name) + (tab && tab !== 'overview' ? '&tab=' + enc(tab) : '');
73
73
  }
74
74
 
75
- // Switching agents keeps you where you are: on a config tab you land on
76
- // the same tab for the other agent, anywhere else you land in its Chat.
75
+ // An agent in the list opens its Overview. Switching agents keeps you
76
+ // where you are: on a config tab you land on the same tab for the other
77
+ // agent, and in Chat on its Chat.
77
78
  function agentHref(name) {
78
79
  if (page === 'agent-config') return tabHref(name, params.get('tab') || 'overview');
79
- return tabHref(name, 'chat');
80
+ if (page === 'chat') return tabHref(name, 'chat');
81
+ return tabHref(name, 'overview');
80
82
  }
81
83
 
82
84
  function renderList() {
@@ -5,6 +5,7 @@ import { type GeneratedMcpAbility, type McpAbilityRequest } from '../core/mcp-ab
5
5
  import type { McpDirectoryServer } from '../core/mcp-directory.js';
6
6
  import { type Workflow } from '../core/workflows.js';
7
7
  import { type WorkflowRun } from '../core/workflow-runs.js';
8
+ import { type ScheduledTask } from '../core/scheduler.js';
8
9
  import type { ModelCall } from '../core/run-agent.js';
9
10
  export declare class WorkflowError extends Error {
10
11
  }
@@ -162,6 +163,9 @@ type WorkflowScheduleView = {
162
163
  taskId: string;
163
164
  status: string;
164
165
  repeating: boolean;
166
+ describe: string;
167
+ name?: string;
168
+ notify: boolean;
165
169
  params?: Record<string, string>;
166
170
  nextRunAt?: string;
167
171
  lastRunAt?: string;
@@ -175,7 +179,12 @@ export interface AgentWorkflowView extends Workflow {
175
179
  schedules: WorkflowScheduleView[];
176
180
  /** Its latest runs started by hand or by an app, newest first. */
177
181
  recentRuns: WorkflowRun[];
182
+ /** Its webhook triggers that are on. */
183
+ webhooks: number;
178
184
  }
185
+ /** A schedule in words, in its time zone: "every day at 18:05", "every
186
+ * Monday at 09:00", "every 10 minutes", "once on Nov 6 at 18:05". */
187
+ export declare function plainSchedule(schedule: ScheduledTask['schedule']): string;
179
188
  /** Moves an agent's workflows.yml, from before workflows were skills, into
180
189
  * its skills, and points the schedules it linked by name at the workflows
181
190
  * they run. Safe to call any time: it does nothing once the file is gone. */
@@ -192,14 +201,19 @@ export declare function prepareWorkflowRun(agentName: string, agentDir: string,
192
201
  params: Record<string, string>;
193
202
  message: string;
194
203
  }>;
195
- /** Changes how a workflow's runs start, from the console's workflow
196
- * editor: its title, start message, trigger and parameters. Its steps
197
- * (the skill body) and the skill's other front matter are kept. */
204
+ /** Changes a workflow from the console's editor: its title, its
205
+ * instructions (the skill body, which an older workflow's start message
206
+ * and label are folded into), and its parameters. The skill's other front
207
+ * matter is kept. A body with `start_message` (and no `instructions`) is
208
+ * still taken, as before. */
198
209
  export declare function updateWorkflow(agentDir: string, id: string, input: Record<string, unknown>): Workflow;
199
- /** Forgets a workflow: its skill stays, as a plain skill, and the
200
- * schedules that ran it are cancelled. Returns how many were. */
210
+ /** Removes a workflow: its skill stays, as a plain skill the agent can
211
+ * still use in chat, and what started it goes: its schedules are
212
+ * cancelled and its webhooks deleted (their URLs stop working). Returns
213
+ * how many of each. */
201
214
  export declare function forgetWorkflow(agentName: string, agentDir: string, id: string): Promise<{
202
215
  cancelled: number;
216
+ webhooks: number;
203
217
  }>;
204
218
  export declare function agentDirIn(projectDir: string, name: string): string;
205
219
  export {};
@@ -11,12 +11,12 @@ 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
13
  import { planParameters, planWebhook, planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
14
- import { createTrigger, publicTrigger } from '../core/workflow-triggers.js';
14
+ import { createTrigger, getTriggerStore, listTriggers, publicTrigger } from '../core/workflow-triggers.js';
15
15
  import { mcpAbilityNames } from '../core/mcp-ability-generator.js';
16
16
  import { clearWorkflowFields, migrateWorkflowsFile, readWorkflow, readWorkflows, setWorkflowFields, validateParams, workflowRunMessage } from '../core/workflows.js';
17
17
  import { isUnderWay, listRuns } from '../core/workflow-runs.js';
18
- import { cancelScheduledTask, createScheduledTask, getSchedulerStore, listScheduledTasks, pauseScheduledTask } from '../core/scheduler.js';
19
- import { workspaceTimeZone } from '../core/time-zone.js';
18
+ import { cancelScheduledTask, createScheduledTask, describeSchedule, getSchedulerStore, listScheduledTasks, pauseScheduledTask } from '../core/scheduler.js';
19
+ import { wallTimeAt, workspaceTimeZone } from '../core/time-zone.js';
20
20
  export class WorkflowError extends Error {
21
21
  }
22
22
  /** Checks a plan against the agent it would be added to. */
@@ -316,7 +316,7 @@ async function makePlannedWebhook(plan, agent, agentDir) {
316
316
  webhook: { trigger: publicTrigger(trigger), token, ...(webhook.setup ? { setup: webhook.setup } : {}) },
317
317
  checklist: [{
318
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`,
319
+ href: `/agents/config?agent=${encodeURIComponent(agent)}&tab=workflows`,
320
320
  }],
321
321
  };
322
322
  }
@@ -342,7 +342,9 @@ export async function createWorkflow(input, deps) {
342
342
  // matter. Its parameters are tidied again, as the plan may have been
343
343
  // edited on the review screen.
344
344
  const { parameters } = planParameters(plan.parameters, { startMessage: plan.startMessage, skillBody: plan.skill.body, scheduled: plan.trigger.kind === 'schedule' });
345
- const markWorkflow = (agentDir) => setWorkflowFields(agentDir, plan.id, { title: plan.name, startMessage: plan.startMessage, trigger: plan.trigger.description, parameters });
345
+ // One text, as the editor shows it: the start message, then the steps.
346
+ const instructions = plan.skill.body.trim().startsWith(plan.startMessage.trim()) ? plan.skill.body : `${plan.startMessage.trim()}\n\n${plan.skill.body.trim()}`;
347
+ const markWorkflow = (agentDir) => setWorkflowFields(agentDir, plan.id, { title: plan.name, instructions, parameters });
346
348
  const agents = await deps.agentInfo();
347
349
  resolveConnections(plan, agents);
348
350
  const keys = input.mcpKeys && typeof input.mcpKeys === 'object' ? Object.fromEntries(Object.entries(input.mcpKeys).flatMap(([k, v]) => (typeof v === 'string' && v.trim() ? [[k, v.trim()]] : []))) : {};
@@ -491,11 +493,46 @@ function workflowTasks(id, tasks) {
491
493
  .filter((t) => t.workflow_id === id && t.status !== 'cancelled' && t.status !== 'done')
492
494
  .sort((a, b) => Number('every' in b.schedule) - Number('every' in a.schedule));
493
495
  }
496
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
497
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
498
+ /** A schedule in words, in its time zone: "every day at 18:05", "every
499
+ * Monday at 09:00", "every 10 minutes", "once on Nov 6 at 18:05". */
500
+ export function plainSchedule(schedule) {
501
+ const zone = ('time_zone' in schedule && schedule.time_zone) || workspaceTimeZone();
502
+ const at = (iso) => {
503
+ const t = wallTimeAt(Date.parse(iso), zone);
504
+ return { ...t, clock: `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}` };
505
+ };
506
+ if ('run_at' in schedule) {
507
+ if (Number.isNaN(Date.parse(schedule.run_at)))
508
+ return `once at ${schedule.run_at}`;
509
+ const t = at(schedule.run_at);
510
+ return `once on ${MONTHS[t.month - 1]} ${t.day} at ${t.clock}`;
511
+ }
512
+ const m = /^(\d+)([mhdw])$/.exec(schedule.every);
513
+ if (!m)
514
+ return describeSchedule(schedule);
515
+ const n = Number(m[1]);
516
+ const unit = m[2];
517
+ const anchor = schedule.anchor && !Number.isNaN(Date.parse(schedule.anchor)) ? at(schedule.anchor) : undefined;
518
+ if (unit === 'm')
519
+ return n === 1 ? 'every minute' : `every ${n} minutes`;
520
+ if (unit === 'h')
521
+ return (n === 1 ? 'every hour' : `every ${n} hours`) + (anchor && n === 1 ? ` at :${String(anchor.minute).padStart(2, '0')}` : '');
522
+ if (unit === 'd')
523
+ return (n === 1 ? 'every day' : `every ${n} days`) + (anchor ? ` at ${anchor.clock}` : '');
524
+ if (n === 1 && anchor)
525
+ return `every ${WEEKDAYS[anchor.weekday]} at ${anchor.clock}`;
526
+ return (n === 1 ? 'every week' : `every ${n} weeks`) + (anchor ? ` on ${WEEKDAYS[anchor.weekday]} at ${anchor.clock}` : '');
527
+ }
494
528
  function scheduleView(task) {
495
529
  return {
496
530
  taskId: task.task_id,
497
531
  status: task.status,
498
532
  repeating: 'every' in task.schedule,
533
+ describe: plainSchedule(task.schedule),
534
+ ...(task.task_name ? { name: task.task_name } : {}),
535
+ notify: Boolean(task.notify),
499
536
  ...(task.params ? { params: task.params } : {}),
500
537
  ...(task.next_run_at ? { nextRunAt: task.next_run_at } : {}),
501
538
  ...(task.last_run_at ? { lastRunAt: task.last_run_at } : {}),
@@ -525,10 +562,12 @@ export async function listAgentWorkflows(agentName, agentDir) {
525
562
  return [];
526
563
  const tasks = await listScheduledTasks({ agent: agentName });
527
564
  const runs = await listRuns(agentName);
565
+ const triggers = await listTriggers(agentName);
528
566
  return workflows.map((w) => {
529
567
  const schedules = workflowTasks(w.id, tasks).map(scheduleView);
530
568
  const recentRuns = runs.filter((r) => r.workflow_id === w.id).slice(0, RECENT_RUNS);
531
- return { ...w, schedules, recentRuns, ...(schedules[0] ? { schedule: schedules[0] } : {}) };
569
+ const webhooks = triggers.filter((t) => t.workflow_id === w.id && t.enabled).length;
570
+ return { ...w, schedules, recentRuns, webhooks, ...(schedules[0] ? { schedule: schedules[0] } : {}) };
532
571
  });
533
572
  }
534
573
  function sameParams(a, b) {
@@ -558,9 +597,11 @@ export async function prepareWorkflowRun(agentName, agentDir, id, rawParams) {
558
597
  }
559
598
  return { workflow, params, message: workflowRunMessage(workflow, params) };
560
599
  }
561
- /** Changes how a workflow's runs start, from the console's workflow
562
- * editor: its title, start message, trigger and parameters. Its steps
563
- * (the skill body) and the skill's other front matter are kept. */
600
+ /** Changes a workflow from the console's editor: its title, its
601
+ * instructions (the skill body, which an older workflow's start message
602
+ * and label are folded into), and its parameters. The skill's other front
603
+ * matter is kept. A body with `start_message` (and no `instructions`) is
604
+ * still taken, as before. */
564
605
  export function updateWorkflow(agentDir, id, input) {
565
606
  if (!readWorkflow(agentDir, id))
566
607
  throw new WorkflowError(`No workflow ${id}.`);
@@ -573,10 +614,13 @@ export function updateWorkflow(agentDir, id, input) {
573
614
  return trimmed;
574
615
  };
575
616
  const title = text(input.title, 'The title', 120);
576
- const startMessage = text(input.start_message, 'The start message', 4000);
577
- if (!startMessage)
578
- throw new WorkflowError('Write the start message: what each run begins with, e.g. "Check the refund request for order $order_number."');
579
- const trigger = text(input.trigger, 'When it runs', 200);
617
+ const instructions = input.instructions === undefined ? undefined : text(input.instructions, 'The instructions', 12000);
618
+ if (instructions !== undefined && !instructions)
619
+ throw new WorkflowError('Write the instructions: what the agent does, e.g. "Check the refund request for order $order_number: look up the order, then…"');
620
+ const startMessage = instructions === undefined ? text(input.start_message, 'The start message', 4000) : '';
621
+ if (instructions === undefined && !startMessage)
622
+ throw new WorkflowError('Write the instructions: what the agent does each run.');
623
+ const trigger = instructions === undefined ? text(input.trigger, 'When it runs', 200) : '';
580
624
  const raw = input.parameters ?? {};
581
625
  if (typeof raw !== 'object' || Array.isArray(raw))
582
626
  throw new WorkflowError('parameters must be an object of names to { description, required }.');
@@ -587,11 +631,13 @@ export function updateWorkflow(agentDir, id, input) {
587
631
  const s = spec && typeof spec === 'object' ? spec : {};
588
632
  parameters[name] = { description: text(s.description, `The description of "${name}"`, 300), required: s.required === true };
589
633
  }
590
- setWorkflowFields(agentDir, id, { ...(title ? { title } : {}), startMessage, ...(trigger ? { trigger } : {}), parameters });
634
+ setWorkflowFields(agentDir, id, instructions !== undefined ? { ...(title ? { title } : {}), instructions, parameters } : { ...(title ? { title } : {}), startMessage, ...(trigger ? { trigger } : {}), parameters });
591
635
  return readWorkflow(agentDir, id);
592
636
  }
593
- /** Forgets a workflow: its skill stays, as a plain skill, and the
594
- * schedules that ran it are cancelled. Returns how many were. */
637
+ /** Removes a workflow: its skill stays, as a plain skill the agent can
638
+ * still use in chat, and what started it goes: its schedules are
639
+ * cancelled and its webhooks deleted (their URLs stop working). Returns
640
+ * how many of each. */
595
641
  export async function forgetWorkflow(agentName, agentDir, id) {
596
642
  clearWorkflowFields(agentDir, id);
597
643
  let cancelled = 0;
@@ -599,7 +645,12 @@ export async function forgetWorkflow(agentName, agentDir, id) {
599
645
  await cancelScheduledTask(task.task_id);
600
646
  cancelled++;
601
647
  }
602
- return { cancelled };
648
+ let webhooks = 0;
649
+ for (const trigger of await listTriggers(agentName, id)) {
650
+ await getTriggerStore().remove(trigger.trigger_id);
651
+ webhooks++;
652
+ }
653
+ return { cancelled, webhooks };
603
654
  }
604
655
  export function agentDirIn(projectDir, name) {
605
656
  return join(projectDir, 'agents', name);
@@ -327,7 +327,7 @@ ${consoleShellScript}
327
327
 
328
328
  // ---- 3. Done ----
329
329
  function renderDone(c) {
330
- var agentHref = '/agents/config?agent=' + encodeURIComponent(c.agent);
330
+ var agentHref = '/agents/config?agent=' + encodeURIComponent(c.agent) + '&tab=workflows';
331
331
  var items = c.checklist || [];
332
332
  page.innerHTML = '<div class="wf-card">' +
333
333
  '<h2>' + esc(c.workflow) + ' is ready</h2>' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",