woml-cli 1.0.4 → 1.0.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.
package/README.md CHANGED
@@ -1,81 +1,404 @@
1
- <div align="center">
2
-
3
1
  # WOML: Workflow Orchestration Markup Language
4
2
 
5
- ![WOML banner](./woml.png)
3
+ WOML is a declarative markup language for building and running production-grade workflow automation. A WOML workflow is a structured, HTML-inspired document that compiles into a typed, durable execution graph. Triggers, steps, control flow, lifecycle hooks, concurrency, and human approvals all live in one readable file with a Rust engine underneath.
4
+
5
+ When a step needs real logic, JavaScript runs inside `<script>`, so there is no ceiling on what a workflow can do. WOML handles everything around that code — execution order, retries, concurrency, lifecycle, human-in-the-loop, external services, and a durable, inspectable history of every run.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g woml-cli
13
+ ```
14
+
15
+ Or with Bun:
16
+
17
+ ```bash
18
+ bun add -g woml-cli
19
+ ```
20
+
21
+ Or with pnpm:
6
22
 
7
- ### If you can read HTML, you can use WOML to automate anything, literally anything.
23
+ ```bash
24
+ pnpm add -g woml-cli
25
+ ```
8
26
 
9
- <!-- WOML banner image placeholder: ./docs/assets/banner.png -->
27
+ Verify:
10
28
 
11
- [![npm version](https://img.shields.io/npm/v/woml-cli.svg)](https://www.npmjs.com/package/woml-cli)
12
- [![GitHub stars](https://img.shields.io/github/stars/dali-benothmen/woml.svg?style=social)](https://github.com/dali-benothmen/woml)
13
- [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)
14
- [![Platforms](https://img.shields.io/badge/platforms-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey.svg)]()
29
+ ```bash
30
+ woml --version
31
+ ```
15
32
 
16
- </div>
33
+ **Requirements:** macOS (x64, arm64), Linux (x64, glibc), or Windows (x64, arm64). No database to set up — the default state store is bundled.
17
34
 
18
35
  ---
19
36
 
20
- WOML is a markup language for building and running workflow automation. A workflow written in WOML is a document you can read top to bottom, its triggers, steps, control flow, approvals, and lifecycle all expressed as clear, HTML-inspired structure instead of tangled code or an unreadable diagram.
37
+ ## Document structure
21
38
 
22
- When a step needs real logic, JavaScript is always available inside `<script>`, so there is no ceiling on what a workflow can do. WOML handles everything _around_ that code: execution order, retries, concurrency, human approvals, external services, and a durable, inspectable history of every run. The result is automation that scales without becoming spaghetti, that your whole team can read, and that you can actually trust in production.
39
+ A workflow is one `<woml>` document with a `<workflow>` root plus the standard containers: `<config>` for runtime policies, `<lifecycle>` for hooks, `<triggers>` for what starts a run, and `<steps>` for what runs.
23
40
 
24
- ## Why another automation tool?
41
+ ```xml
42
+ <woml>
43
+ <workflow id="..." name="..." version="1.0.0">
44
+ <config concurrency="4" timeout="10m" />
45
+ <lifecycle>
46
+ <on-success><script>...</script></on-success>
47
+ <on-error><script>...</script></on-error>
48
+ </lifecycle>
49
+ <triggers>...</triggers>
50
+ <steps>...</steps>
51
+ </workflow>
52
+ </woml>
53
+ ```
25
54
 
26
- Five nodes in n8n or Zapier feels like magic. Twenty nodes feels like a crime scene.
55
+ Runtime bindings available inside every `<script>`:
27
56
 
28
- The canvas turns into spaghetti, a single run takes a lifetime, and the moment the built-in integrations fall short you end up stuffing JavaScript into a tiny textbox in a browser UI, no version control, no code review, no idea what changed last Tuesday.
57
+ - `context.payload` trigger input
58
+ - `context.steps.<id>` — earlier step output
59
+ - `context.run` — durable run metadata
60
+ - `services.http`, `services.database`, `services.slack`, `services.storage`, `services.cache`, `services.event`, `services.messaging` — supervised capabilities (each must be declared by the workflow or its modules)
61
+ - `secrets.<NAME>` — only the secrets proven necessary at compile time
29
62
 
30
- WOML takes a different bet: your workflow is **a file**. It reads like HTML, so anyone on the team can follow it. Every step can run real JavaScript with any npm package, so you never hit a wall. It lives in git, so every change is a diff and a review. And the engine underneath is Rust, so it stays fast when your workflows get big, because big workflows are exactly what WOML is built for.
63
+ Scripts return JSON-compatible values. The Rust engine records every outcome durably.
31
64
 
32
- ## Why WOML
65
+ ---
33
66
 
34
- - **Readable as a document.** A workflow is structure you can read, diff, and review, not a canvas of boxes and wires that turns into spaghetti as it grows.
35
- - **No ceiling.** Common actions are clean tags; when you need real logic, drop into `<script>` with full JavaScript and any npm package. Automate anything, literally anything.
36
- - **Triggers for everything.** Manual, webhook, schedule, interval, event, Slack, Telegram, Discord, and WhatsApp, and you can build your own providers for anything else.
37
- - **Built to run in production.** Durable run history, retries, concurrency, lifecycle hooks, and a fold-from-events core mean you can always see what happened, track down errors, and replay it.
38
- - **Human-in-the-loop.** Pause a workflow for approvals with real notifications and durable waiting.
39
- - **Modular.** Reusable step definitions, local modules, and workflows that call other workflows.
40
- - **Fast core.** The execution engine is written in Rust for speed and reliability under load.
41
- - **Free and runs anywhere.** Open source, self-hosted, runs on macOS, Linux, and Windows.
67
+ ## Triggers
42
68
 
43
- ## Installation
69
+ Triggers decide **when** a workflow runs. Place one or more inside `<triggers>`.
44
70
 
45
- ```bash
46
- npm i -g woml-cli
71
+ ### `<manual>` — run on demand
72
+
73
+ The simplest trigger. Starts a run when the operator calls `woml run` with a payload.
74
+
75
+ ```xml
76
+ <manual id="start" />
47
77
  ```
48
78
 
49
- Or with Bun:
79
+ ### `<webhook>` — accept HTTP requests
50
80
 
51
- ```bash
52
- bun add --global woml-cli
81
+ Registers a static HTTP route that starts a run for every validated payload.
82
+
83
+ ```xml
84
+ <webhook id="hook"
85
+ path="/webhooks/orders"
86
+ method="POST"
87
+ auth="bearer"
88
+ secret="{{secrets.ORDER_WEBHOOK_TOKEN}}">
89
+ <schema>
90
+ { "type": "object", "required": ["orderId"], "properties": { "orderId": { "type": "string" } } }
91
+ </schema>
92
+ </webhook>
53
93
  ```
54
94
 
55
- Or with pnpm:
95
+ - `auth="bearer"` requires a `secret`; `auth="none"` is for deliberately public routes.
96
+ - The inline `<schema>` is JSON Schema Draft 2020-12; invalid payloads return `400 Bad Request` with the `WOML_TRIGGER_SCHEMA_INVALID` code and never start a run.
56
97
 
57
- ```bash
58
- pnpm add -g woml-cli
98
+ ### `<schedule>` — cron expressions
99
+
100
+ Starts a run on a WOML Cron v1 schedule. Five numeric fields (`minute hour day-of-month month day-of-week`), wildcards, lists, inclusive ranges, and `/step` are supported. Seconds, names, and Quartz-only tokens are rejected.
101
+
102
+ ```xml
103
+ <schedule id="daily"
104
+ cron="0 9 * * MON-FRI"
105
+ timezone="UTC"
106
+ on-missed="skip" />
59
107
  ```
60
108
 
61
- This installs the `woml` command:
109
+ `timezone` defaults to UTC. `on-missed` chooses `skip` or `run-once` after a restart.
62
110
 
63
- ```bash
64
- woml --version
111
+ ### `<interval>` — fixed cadence
112
+
113
+ Starts a run on a fixed interval. The compiler must not translate it into cron if semantics would change.
114
+
115
+ ```xml
116
+ <interval id="heartbeat" every="30s" on-missed="skip" />
117
+ ```
118
+
119
+ ### `<event>` — react to internal events
120
+
121
+ Starts a run when another workflow emits a named event through the durable event bus.
122
+
123
+ ```xml
124
+ <event id="created"
125
+ name="order.created"
126
+ secret="{{secrets.EVENT_CONTROL_TOKEN}}">
127
+ <schema>
128
+ { "type": "object", "required": ["orderId"], "properties": { "orderId": { "type": "string" } } }
129
+ </schema>
130
+ </event>
131
+ ```
132
+
133
+ ### `<slack>` — Slack Socket Mode
134
+
135
+ Starts a run for Slack workspace events via a single Socket Mode connection per credential pair.
136
+
137
+ ```xml
138
+ <slack id="msg"
139
+ events="app-mention,direct-message"
140
+ channels="ops,alerts"
141
+ bot-token="{{secrets.SLACK_BOT_TOKEN}}"
142
+ app-token="{{secrets.SLACK_APP_TOKEN}}" />
143
+ ```
144
+
145
+ `events` accepts `app-mention` and `direct-message`. `channels` is optional and limits mentions to a comma-separated set.
146
+
147
+ ### `<telegram>` — Telegram long polling
148
+
149
+ Starts a run for every incoming Telegram message via long polling and durable admission.
150
+
151
+ ```xml
152
+ <telegram id="bot"
153
+ events="message"
154
+ bot-token="{{secrets.TELEGRAM_BOT_TOKEN}}" />
155
+ ```
156
+
157
+ Telegram v1 supports the single `message` event.
158
+
159
+ ### `<discord>` — Discord Gateway
160
+
161
+ Starts a run for Discord activity via a shared resumable Gateway connection.
162
+
163
+ ```xml
164
+ <discord id="bot"
165
+ events="app-mention,direct-message"
166
+ bot-token="{{secrets.DISCORD_BOT_TOKEN}}" />
167
+ ```
168
+
169
+ `channels` is optional and accepts comma-separated numeric channel IDs (17–20 digits). Channel names are rejected because they are mutable display labels.
170
+
171
+ ### `<whatsapp>` — WhatsApp Cloud API
172
+
173
+ Starts a run for inbound WhatsApp messages via signed Meta Cloud API callbacks.
174
+
175
+ ```xml
176
+ <whatsapp id="bot"
177
+ events="message"
178
+ phone-number-id="123456789012345"
179
+ verify-token="{{secrets.WHATSAPP_VERIFY_TOKEN}}"
180
+ app-secret="{{secrets.WHATSAPP_APP_SECRET}}" />
65
181
  ```
66
182
 
67
- **Requirements:** macOS (x64, arm64), Linux (x64, glibc), or Windows (x64, arm64). No database to set up, the default state store is bundled.
183
+ `phone-number-id` is Meta's durable Phone Number ID, not the display phone number.
184
+
185
+ ---
68
186
 
69
- ## Quick example: organize your Downloads folder
187
+ ## Steps
70
188
 
71
- Run this once and it sorts every file in a folder into the right place — images, documents, videos, archives — using conditional logic, a real filesystem loop, and zero external services.
189
+ Steps run sequentially inside `<steps>`. Each `<step>` returns a value that becomes available at `context.steps.<stepId>`.
190
+
191
+ ```xml
192
+ <step id="greet">
193
+ <script>
194
+ return { message: `Hello, ${context.payload.name}!` };
195
+ </script>
196
+ </step>
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Control flow
202
+
203
+ Two compact routing primitives cover most branching needs.
204
+
205
+ ### `<choose>` — mutually exclusive routes
206
+
207
+ `<choose id="...">` selects the first `<when>` whose `test` reference is true and publishes a merged result at `context.steps.<chooseId>`. The `test` attribute holds exactly one context reference — complex conditions belong in named steps.
208
+
209
+ ```xml
210
+ <step id="needsReview">
211
+ <script>
212
+ return { value: context.steps.analysis.risk > 0.3 };
213
+ </script>
214
+ </step>
215
+
216
+ <choose id="reviewRoute">
217
+ <when test="{{context.steps.needsReview.value}}">
218
+ <step id="humanDecision">
219
+ <script>return { routed: 'review' };</script>
220
+ </step>
221
+ <result value="{{context.steps.humanDecision}}" />
222
+ </when>
223
+ <otherwise>
224
+ <step id="automaticDecision">
225
+ <script>return { routed: 'auto' };</script>
226
+ </step>
227
+ <result value="{{context.steps.automaticDecision}}" />
228
+ </otherwise>
229
+ </choose>
230
+ ```
231
+
232
+ ### `<switch>` — exact-string routing
233
+
234
+ `<switch id="..." value="...">` compares one context reference against ordered string cases and runs exactly one route.
235
+
236
+ ```xml
237
+ <switch id="route" value="{{context.steps.classify.intent}}">
238
+ <case value="bug">
239
+ <step id="sendBugs">
240
+ <script>return { routedTo: 'bugs' };</script>
241
+ </step>
242
+ <result value="{{context.steps.sendBugs}}" />
243
+ </case>
244
+ <default>
245
+ <step id="dropNoise">
246
+ <script>return { dropped: true };</script>
247
+ </step>
248
+ <result value="{{context.steps.dropNoise}}" />
249
+ </default>
250
+ </switch>
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Concurrent steps — `<parallel>`
256
+
257
+ `<parallel>` runs its direct child steps concurrently and joins after they finish. A one-step parallel is a valid degenerate fork/join.
258
+
259
+ ```xml
260
+ <parallel id="fieldData" concurrency="2" on-error="wait-all">
261
+ <step id="loadWeather">
262
+ <script>return loadWeather(context.payload.fieldId);</script>
263
+ </step>
264
+ <step id="loadSoil">
265
+ <script>return loadSoil(context.payload.fieldId);</script>
266
+ </step>
267
+ </parallel>
268
+ ```
269
+
270
+ - `concurrency` caps simultaneous child steps; defaults to the number of children.
271
+ - `on-error` is `fail-fast` (default) or `wait-all`. `fail-fast` stops scheduling new children; `wait-all` lets every child reach its terminal outcome first.
272
+ - All children see the same context view from immediately before the fork.
273
+ - A child cannot reference a sibling's output.
274
+
275
+ For multi-step concurrent routes (each branch holds its own sequence of steps), use `<fork>` and `<branch>` instead.
276
+
277
+ ---
278
+
279
+ ## Concurrent routes — `<fork>` and `<branch>`
280
+
281
+ `<fork>` runs multiple multi-step branches concurrently and joins on a chosen set. Each `<branch>` may contain steps, choices, switches, parallel groups, and approvals. Branches remain sequential internally while overlapping through the multiplexed Bun host.
282
+
283
+ ```xml
284
+ <fork id="distribution" join="all">
285
+ <branch id="tiktok">
286
+ <step id="formatTikTok">
287
+ <script>return { caption: `${context.steps.campaign.title} #automation` };</script>
288
+ </step>
289
+ <step id="publishTikTok">
290
+ <script>return { platform: 'tiktok', caption: context.steps.formatTikTok.caption };</script>
291
+ </step>
292
+ </branch>
293
+ <branch id="instagram">
294
+ <step id="formatInstagram">
295
+ <script>return { caption: `${context.steps.campaign.title}\n${context.steps.campaign.url}` };</script>
296
+ </step>
297
+ <step id="publishInstagram">
298
+ <script>return { platform: 'instagram', caption: context.steps.formatInstagram.caption };</script>
299
+ </step>
300
+ </branch>
301
+ </fork>
302
+ ```
303
+
304
+ - `join="all"` (or omitted) waits for every branch.
305
+ - `join="none"` waits for none.
306
+ - A whitespace-separated branch-ID list waits only for those branches.
307
+ - A branch can read context available before the fork and outputs created earlier in that same branch; it cannot read sibling-branch outputs.
308
+ - Nested forks inside a fork-owned branch are rejected.
309
+ - A workflow whose only terminal structure is a fork is rejected.
310
+
311
+ ---
312
+
313
+ ## Human approvals — `<approval>`
314
+
315
+ `<approval>` is a first-class durable control-flow item. It records that a run is waiting for a decision, optionally fires notifications, suspends the run, and selects exactly one continuation after the decision arrives.
316
+
317
+ ```xml
318
+ <approval id="contentApproval"
319
+ name="Content approval"
320
+ description="Ask a moderator to approve or reject"
321
+ timeout="24h"
322
+ on-timeout="reject">
323
+ <notify>
324
+ <slack channels="moderators"
325
+ bot-token="{{secrets.SLACK_BOT_TOKEN}}"
326
+ app-token="{{secrets.SLACK_APP_TOKEN}}" />
327
+ </notify>
328
+
329
+ <step id="hold" />
330
+ <when-approved>
331
+ <step id="publish">
332
+ <script>return { published: true };</script>
333
+ </step>
334
+ <result value="{{context.steps.publish}}" />
335
+ </when-approved>
336
+ <when-rejected>
337
+ <step id="archive">
338
+ <script>return { archived: true };</script>
339
+ </step>
340
+ <result value="{{context.steps.archive}}" />
341
+ </when-rejected>
342
+ </approval>
343
+ ```
344
+
345
+ - `timeout` caps how long the approval waits; `on-timeout` chooses `approve`, `reject`, or another arm.
346
+ - Optional `<notify>` fires built-in Slack/Telegram/Discord/WhatsApp notifications when the approval is armed.
347
+ - Exactly one `<when-approved>` or `<when-rejected>` is selected after the decision arrives.
348
+
349
+ ---
350
+
351
+ ## Notifications — `<notify>`
352
+
353
+ `<notify>` is a container for built-in Slack, Telegram, Discord, or WhatsApp deliveries. It is not a standalone step — it is attached to the parent that arms the notification.
354
+
355
+ ### Inside lifecycle hooks
356
+
357
+ Fire a notification when the run finishes successfully or fails.
358
+
359
+ ```xml
360
+ <lifecycle>
361
+ <on-success>
362
+ <notify>
363
+ <slack channels="ops"
364
+ bot-token="{{secrets.SLACK_BOT_TOKEN}}"
365
+ app-token="{{secrets.SLACK_APP_TOKEN}}" />
366
+ </notify>
367
+ <script>
368
+ console.log('Run completed successfully');
369
+ </script>
370
+ </on-success>
371
+ <on-error>
372
+ <notify>
373
+ <slack channels="oncall"
374
+ bot-token="{{secrets.SLACK_BOT_TOKEN}}"
375
+ app-token="{{secrets.SLACK_APP_TOKEN}}" />
376
+ </notify>
377
+ <script>
378
+ console.error('Run failed');
379
+ </script>
380
+ </on-error>
381
+ </lifecycle>
382
+ ```
383
+
384
+ ### Inside an approval
385
+
386
+ Fire a notification when the approval is armed so the right moderator sees the decision request (see the `<approval>` example above).
387
+
388
+ A `<notify>` contains one or more built-in provider tags — `<slack>`, `<telegram>`, `<discord>`, or `<whatsapp>` — and must not contain anything else.
389
+
390
+ ---
391
+
392
+ ## Real examples
393
+
394
+ ### Local automation — organize a folder by file type
395
+
396
+ Run once, sorts every file into the right subfolder. Zero external services, zero API keys.
72
397
 
73
398
  ```xml
74
399
  <woml>
75
- <workflow id="organize" name="Organize a folder by file type">
76
- <triggers>
77
- <manual id="start" />
78
- </triggers>
400
+ <workflow id="organize" name="Organize a folder by file type" version="1.0.0">
401
+ <triggers><manual id="start" /></triggers>
79
402
 
80
403
  <steps>
81
404
  <step id="scan">
@@ -93,86 +416,70 @@ Run this once and it sorts every file in a folder into the right place — image
93
416
  </script>
94
417
  </step>
95
418
 
96
- <for-each id="organize" source="{{context.steps.scan.files}}">
97
- <step id="classify">
419
+ <parallel id="moveAll" concurrency="4">
420
+ <step id="moveImages">
98
421
  <script>
99
- return { ext: context.item.ext };
422
+ const { promises: fs } = await import('fs');
423
+ const path = await import('path');
424
+ const { folder, files } = context.steps.scan;
425
+ for (const f of files.filter(f => ['.jpg','.jpeg','.png','.gif','.webp','.svg'].includes(f.ext))) {
426
+ const from = path.join(folder, f.name);
427
+ const to = path.join(folder, 'Images', f.name);
428
+ await fs.mkdir(path.dirname(to), { recursive: true });
429
+ await fs.rename(from, to);
430
+ }
431
+ return { ok: true };
100
432
  </script>
101
433
  </step>
102
-
103
- <choose>
104
- <when test="{{context.steps.classify.ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']}}">
105
- <step id="move-image">
106
- <script>
107
- const { promises: fs } = await import('fs');
108
- const path = await import('path');
109
- const from = path.join(context.steps.scan.folder, context.item.name);
110
- const to = path.join(context.steps.scan.folder, 'Images', context.item.name);
111
- await fs.mkdir(path.dirname(to), { recursive: true });
112
- await fs.rename(from, to);
113
- return { movedTo: 'Images' };
114
- </script>
115
- </step>
116
- </when>
117
- <when test="{{context.steps.classify.ext in ['.pdf', '.doc', '.docx', '.txt', '.md', '.rtf']}}">
118
- <step id="move-doc">
119
- <script>
120
- const { promises: fs } = await import('fs');
121
- const path = await import('path');
122
- const from = path.join(context.steps.scan.folder, context.item.name);
123
- const to = path.join(context.steps.scan.folder, 'Docs', context.item.name);
124
- await fs.mkdir(path.dirname(to), { recursive: true });
125
- await fs.rename(from, to);
126
- return { movedTo: 'Docs' };
127
- </script>
128
- </step>
129
- </when>
130
- <when test="{{context.steps.classify.ext in ['.mp4', '.mov', '.avi', '.mkv', '.webm']}}">
131
- <step id="move-video">
132
- <script>
133
- const { promises: fs } = await import('fs');
134
- const path = await import('path');
135
- const from = path.join(context.steps.scan.folder, context.item.name);
136
- const to = path.join(context.steps.scan.folder, 'Videos', context.item.name);
137
- await fs.mkdir(path.dirname(to), { recursive: true });
138
- await fs.rename(from, to);
139
- return { movedTo: 'Videos' };
140
- </script>
141
- </step>
142
- </when>
143
- <when test="{{context.steps.classify.ext in ['.zip', '.tar', '.gz', '.7z', '.rar', '.dmg']}}">
144
- <step id="move-archive">
145
- <script>
146
- const { promises: fs } = await import('fs');
147
- const path = await import('path');
148
- const from = path.join(context.steps.scan.folder, context.item.name);
149
- const to = path.join(context.steps.scan.folder, 'Archives', context.item.name);
150
- await fs.mkdir(path.dirname(to), { recursive: true });
151
- await fs.rename(from, to);
152
- return { movedTo: 'Archives' };
153
- </script>
154
- </step>
155
- </when>
156
- <otherwise>
157
- <step id="move-misc">
158
- <script>
159
- const { promises: fs } = await import('fs');
160
- const path = await import('path');
161
- const from = path.join(context.steps.scan.folder, context.item.name);
162
- const to = path.join(context.steps.scan.folder, 'Misc', context.item.name);
163
- await fs.mkdir(path.dirname(to), { recursive: true });
164
- await fs.rename(from, to);
165
- return { movedTo: 'Misc' };
166
- </script>
167
- </step>
168
- </otherwise>
169
- </choose>
170
- </for-each>
434
+ <step id="moveDocs">
435
+ <script>
436
+ const { promises: fs } = await import('fs');
437
+ const path = await import('path');
438
+ const { folder, files } = context.steps.scan;
439
+ for (const f of files.filter(f => ['.pdf','.doc','.docx','.txt','.md','.rtf'].includes(f.ext))) {
440
+ const from = path.join(folder, f.name);
441
+ const to = path.join(folder, 'Docs', f.name);
442
+ await fs.mkdir(path.dirname(to), { recursive: true });
443
+ await fs.rename(from, to);
444
+ }
445
+ return { ok: true };
446
+ </script>
447
+ </step>
448
+ <step id="moveVideos">
449
+ <script>
450
+ const { promises: fs } = await import('fs');
451
+ const path = await import('path');
452
+ const { folder, files } = context.steps.scan;
453
+ for (const f of files.filter(f => ['.mp4','.mov','.avi','.mkv','.webm'].includes(f.ext))) {
454
+ const from = path.join(folder, f.name);
455
+ const to = path.join(folder, 'Videos', f.name);
456
+ await fs.mkdir(path.dirname(to), { recursive: true });
457
+ await fs.rename(from, to);
458
+ }
459
+ return { ok: true };
460
+ </script>
461
+ </step>
462
+ <step id="moveArchives">
463
+ <script>
464
+ const { promises: fs } = await import('fs');
465
+ const path = await import('path');
466
+ const { folder, files } = context.steps.scan;
467
+ for (const f of files.filter(f => ['.zip','.tar','.gz','.7z','.rar'].includes(f.ext))) {
468
+ const from = path.join(folder, f.name);
469
+ const to = path.join(folder, 'Archives', f.name);
470
+ await fs.mkdir(path.dirname(to), { recursive: true });
471
+ await fs.rename(from, to);
472
+ }
473
+ return { ok: true };
474
+ </script>
475
+ </step>
476
+ </parallel>
171
477
 
172
478
  <step id="summary">
173
479
  <script>
174
- const total = context.steps.scan.files.length;
175
- return { message: `Organized ${total} file(s) into Images/, Docs/, Videos/, Archives/, Misc/.` };
480
+ return {
481
+ message: `Organized ${context.steps.scan.files.length} file(s) into Images/, Docs/, Videos/, Archives/.`
482
+ };
176
483
  </script>
177
484
  </step>
178
485
  </steps>
@@ -180,58 +487,127 @@ Run this once and it sorts every file in a folder into the right place — image
180
487
  </woml>
181
488
  ```
182
489
 
183
- Save it as `organize.woml` and run it:
184
-
185
490
  ```bash
186
491
  woml run organize.woml --payload '{"path":"/path/to/Downloads"}'
187
492
  ```
188
493
 
189
- WOML scans the folder, loops over every file with `<for-each>`, routes each one through a `<choose>` decision by extension, and moves it into the right subfolder with `fs.rename`. Press Ctrl+C to stop the run.
190
-
191
- ```mermaid
192
- graph TD
193
- A[Manual trigger] --> B[Scan folder for files]
194
- B --> C[For each file]
195
- C --> D{File extension?}
196
- D -->|jpg, png, gif, webp, svg| E[Move to Images/]
197
- D -->|pdf, doc, txt, md| F[Move to Docs/]
198
- D -->|mp4, mov, avi, mkv| G[Move to Videos/]
199
- D -->|zip, tar, gz, dmg| H[Move to Archives/]
200
- D -->|anything else| I[Move to Misc/]
201
- E --> J[Summary: organized N files]
202
- F --> J
203
- G --> J
204
- H --> J
205
- I --> J
206
- ```
494
+ ---
207
495
 
208
- This is one WOML file. No setup, no cloud, no API keys. It runs locally, pulls in real npm packages (`fs`, `path`) on demand, and exercises the same primitives that make n8n workflows become spaghetti and Zapier workflows impossible: `<choose>` for conditional routing, `<for-each>` for iteration, and `<script>` for the parts that need real code.
496
+ ### AI-powered classify Slack messages and route them to the right channel
209
497
 
210
- ## Real workflows in WOML
498
+ Send every incoming Slack message to an LLM, classify intent, and forward to a dedicated channel.
211
499
 
212
- Four triggers, four real automations. Each is a complete, runnable workflow.
500
+ ```xml
501
+ <woml>
502
+ <workflow id="slack-router" version="1.0.0">
503
+ <triggers>
504
+ <slack id="incoming"
505
+ events="app-mention,direct-message"
506
+ channels="inbox"
507
+ bot-token="{{secrets.SLACK_BOT_TOKEN}}"
508
+ app-token="{{secrets.SLACK_APP_TOKEN}}" />
509
+ </triggers>
213
510
 
214
- **Webhook — flag risky orders and alert Slack:**
511
+ <steps>
512
+ <step id="classify">
513
+ <script>
514
+ const response = await services.http.request({
515
+ method: 'POST',
516
+ url: 'https://api.openai.com/v1/chat/completions',
517
+ headers: { authorization: `Bearer ${secrets.OPENAI_API_KEY}` },
518
+ body: {
519
+ model: 'gpt-4o-mini',
520
+ messages: [
521
+ {
522
+ role: 'system',
523
+ content: 'Classify the message into one of: bug, feature, question, noise. Reply with JSON { "intent": "...", "confidence": 0..1 }.'
524
+ },
525
+ { role: 'user', content: context.payload.text }
526
+ ],
527
+ response_format: { type: 'json_object' }
528
+ },
529
+ timeoutMs: 10000
530
+ });
531
+ return JSON.parse(response.body.choices[0].message.content);
532
+ </script>
533
+ </step>
534
+
535
+ <switch id="route" value="{{context.steps.classify.intent}}">
536
+ <case value="bug">
537
+ <step id="sendBugs">
538
+ <script>
539
+ await services.slack.send({
540
+ channel: '#bugs',
541
+ text: `🐛 ${context.payload.text}\n> confidence ${context.steps.classify.confidence}`
542
+ });
543
+ return { routedTo: 'bugs' };
544
+ </script>
545
+ </step>
546
+ <result value="{{context.steps.sendBugs}}" />
547
+ </case>
548
+ <case value="feature">
549
+ <step id="sendFeatures">
550
+ <script>
551
+ await services.slack.send({
552
+ channel: '#feature-requests',
553
+ text: `💡 ${context.payload.text}`
554
+ });
555
+ return { routedTo: 'feature-requests' };
556
+ </script>
557
+ </step>
558
+ <result value="{{context.steps.sendFeatures}}" />
559
+ </case>
560
+ <case value="question">
561
+ <step id="sendQuestions">
562
+ <script>
563
+ await services.slack.send({
564
+ channel: '#questions',
565
+ text: `❓ ${context.payload.text}`
566
+ });
567
+ return { routedTo: 'questions' };
568
+ </script>
569
+ </step>
570
+ <result value="{{context.steps.sendQuestions}}" />
571
+ </case>
572
+ <default>
573
+ <step id="dropNoise">
574
+ <script>return { dropped: true };</script>
575
+ </step>
576
+ <result value="{{context.steps.dropNoise}}" />
577
+ </default>
578
+ </switch>
579
+ </steps>
580
+ </workflow>
581
+ </woml>
582
+ ```
583
+
584
+ ---
585
+
586
+ ### Webhook — flag risky orders, alert Slack
215
587
 
216
588
  ```xml
217
589
  <woml>
218
- <workflow id="order-guard">
590
+ <workflow id="order-guard" version="1.0.0">
219
591
  <triggers>
220
- <webhook id="order" path="/webhooks/orders" method="POST"
592
+ <webhook id="order"
593
+ path="/webhooks/orders"
594
+ method="POST"
595
+ auth="bearer"
221
596
  secret="{{secrets.ORDER_WEBHOOK_TOKEN}}">
222
597
  <schema>
223
598
  {
224
599
  "type": "object",
225
600
  "required": ["orderId", "total", "customerId"],
226
601
  "properties": {
227
- "orderId": { "type": "string" },
228
- "total": { "type": "number" },
602
+ "orderId": { "type": "string" },
603
+ "total": { "type": "number" },
229
604
  "customerId": { "type": "string" }
230
605
  }
231
606
  }
232
607
  </schema>
233
608
  </webhook>
234
609
  </triggers>
610
+
235
611
  <steps>
236
612
  <step id="risk">
237
613
  <script>
@@ -240,35 +616,52 @@ Four triggers, four real automations. Each is a complete, runnable workflow.
240
616
  url: `https://internal.api/customers/${context.payload.customerId}`,
241
617
  timeoutMs: 5000
242
618
  });
243
- return { flagged: context.payload.total > 10000 || customer.body.disputes > 0 };
619
+ return {
620
+ flagged: context.payload.total > 10000 || customer.body.disputes > 0
621
+ };
244
622
  </script>
245
623
  </step>
624
+
625
+ <step id="isFlagged">
626
+ <script>return { value: context.steps.risk.flagged };</script>
627
+ </step>
628
+
629
+ <choose id="alertRoute">
630
+ <when test="{{context.steps.isFlagged.value}}">
631
+ <step id="alert">
632
+ <script>
633
+ await services.slack.send({
634
+ channel: '#fraud',
635
+ text: `High-risk order ${context.payload.orderId} ($${context.payload.total}) needs review.`
636
+ });
637
+ return { alerted: true };
638
+ </script>
639
+ </step>
640
+ <result value="{{context.steps.alert}}" />
641
+ </when>
642
+ <otherwise>
643
+ <step id="logOk">
644
+ <script>return { logged: true };</script>
645
+ </step>
646
+ <result value="{{context.steps.logOk}}" />
647
+ </otherwise>
648
+ </choose>
246
649
  </steps>
247
- <choose>
248
- <when test="context.steps.risk.flagged">
249
- <step id="alert">
250
- <script>
251
- await services.slack.send({
252
- channel: '#fraud',
253
- text: `High-risk order ${context.payload.orderId} ($${context.payload.total}) needs review.`
254
- });
255
- return { alerted: true };
256
- </script>
257
- </step>
258
- </when>
259
- </choose>
260
650
  </workflow>
261
651
  </woml>
262
652
  ```
263
653
 
264
- **Schedule — a daily sales report, every weekday at 9am:**
654
+ ---
655
+
656
+ ### Schedule — daily sales report at 9am weekdays
265
657
 
266
658
  ```xml
267
659
  <woml>
268
- <workflow id="daily-report">
660
+ <workflow id="daily-report" version="1.0.0">
269
661
  <triggers>
270
662
  <schedule id="weekdays" cron="0 9 * * MON-FRI" timezone="UTC" />
271
663
  </triggers>
664
+
272
665
  <steps>
273
666
  <step id="totals">
274
667
  <script>
@@ -279,6 +672,7 @@ Four triggers, four real automations. Each is a complete, runnable workflow.
279
672
  return rows[0];
280
673
  </script>
281
674
  </step>
675
+
282
676
  <step id="publish">
283
677
  <script>
284
678
  const { orders, revenue } = context.steps.totals;
@@ -294,30 +688,26 @@ Four triggers, four real automations. Each is a complete, runnable workflow.
294
688
  </woml>
295
689
  ```
296
690
 
297
- **Telegram — answer `/sales` from your team chat:**
691
+ ---
692
+
693
+ ### Telegram — answer mentions in your team chat
298
694
 
299
695
  ```xml
300
696
  <woml>
301
- <workflow id="telegram-sales">
697
+ <workflow id="telegram-echo" version="1.0.0">
302
698
  <triggers>
303
- <telegram id="ask" events="message" commands="/sales"
699
+ <telegram id="incoming"
700
+ events="message"
304
701
  bot-token="{{secrets.TELEGRAM_BOT_TOKEN}}" />
305
702
  </triggers>
703
+
306
704
  <steps>
307
- <step id="lookup">
308
- <script>
309
- const rows = await services.database.query({
310
- sql: "SELECT COUNT(*) AS today FROM orders WHERE created_at >= date('now')",
311
- parameters: []
312
- });
313
- return { today: rows[0].today };
314
- </script>
315
- </step>
316
- <step id="notify">
705
+ <step id="reply">
317
706
  <script>
318
- await services.slack.send({
319
- channel: '#sales',
320
- text: `Today's numbers were requested on Telegram: ${context.steps.lookup.today} orders so far.`
707
+ await services.messaging.send({
708
+ channel: 'telegram',
709
+ conversationId: context.payload.conversationId,
710
+ text: `You said: ${context.payload.text}`
321
711
  });
322
712
  return { ok: true };
323
713
  </script>
@@ -327,25 +717,30 @@ Four triggers, four real automations. Each is a complete, runnable workflow.
327
717
  </woml>
328
718
  ```
329
719
 
330
- **Event — send a confirmation email whenever another workflow emits `order.created`:**
720
+ ---
721
+
722
+ ### Event — send a confirmation email when another workflow emits `order.created`
331
723
 
332
724
  ```xml
333
725
  <woml>
334
- <workflow id="order-confirmation">
726
+ <workflow id="order-confirmation" version="1.0.0">
335
727
  <triggers>
336
- <event id="created" name="order.created" secret="{{secrets.EVENT_CONTROL_TOKEN}}">
728
+ <event id="created"
729
+ name="order.created"
730
+ secret="{{secrets.EVENT_CONTROL_TOKEN}}">
337
731
  <schema>
338
732
  {
339
733
  "type": "object",
340
734
  "required": ["orderId", "email"],
341
735
  "properties": {
342
736
  "orderId": { "type": "string" },
343
- "email": { "type": "string" }
737
+ "email": { "type": "string" }
344
738
  }
345
739
  }
346
740
  </schema>
347
741
  </event>
348
742
  </triggers>
743
+
349
744
  <steps>
350
745
  <step id="confirm">
351
746
  <script>
@@ -368,34 +763,7 @@ Four triggers, four real automations. Each is a complete, runnable workflow.
368
763
  </woml>
369
764
  ```
370
765
 
371
- Every script gets explicit runtime bindings — `context.payload` (trigger input), `context.steps.<id>` (earlier step output), `services.*` (supervised capabilities like HTTP, database, Slack, storage, cache), and `secrets.*` (only the secrets proven necessary at compile time). Scripts return JSON-compatible values; the Rust engine records every outcome durably.
372
-
373
- **→ [Browse all examples](./examples/)**
374
-
375
- ## WOML vs alternatives
376
-
377
- | Tool | How you write it | Readable by the whole team | Self-hosted | No ceiling |
378
- | -------------- | ------------------- | ----------------------------- | ----------- | ---------- |
379
- | **WOML** | Markup + JavaScript | ✅ | ✅ | ✅ |
380
- | n8n | Visual canvas | ⚠️ Until it becomes spaghetti | ✅ | ❌ |
381
- | Zapier | Visual canvas | ⚠️ Until it becomes spaghetti | ❌ | ❌ |
382
- | Temporal | Code (TS/Go/Java) | ❌ | ✅ | ✅ |
383
- | Step Functions | JSON (ASL) | ❌ | ❌ | ⚠️ |
384
- | Airflow | Python DAGs | ❌ | ✅ | ✅ |
385
-
386
- WOML is the only one that combines all three: readable as markup, free and self-hosted, and unlimited in what it can express.
387
-
388
- ## What WOML includes
389
-
390
- - Manual, webhook, schedule, interval, event, Slack, Telegram, Discord, and WhatsApp triggers.
391
- - Sequential steps, retries, parallel groups, choices, switches, and forked multi-step branches.
392
- - Durable approvals with provider notifications and shared decisions.
393
- - Workflow and step lifecycle hooks.
394
- - Built-in HTTP, SQL database, storage, cache, event, durable-state, messaging, and workflow call/start services.
395
- - Local JavaScript/TypeScript modules, reusable WOML steps, and custom notification providers.
396
- - Runtime concurrency, rate-limit, queue, and timeout policies.
397
- - Foreground and background operation, run inspection, log following, backup, recovery, and retention.
398
- - A VS Code extension with HTML-style markup and embedded JavaScript syntax.
766
+ ---
399
767
 
400
768
  ## Common commands
401
769
 
@@ -411,22 +779,8 @@ woml backup backups/latest # Snapshot the durable state store to a
411
779
  woml prune --before 30d --dry-run # Preview which old runs would be purged
412
780
  ```
413
781
 
414
- See the [CLI reference](docs/cli-reference.md) for every command and option.
415
-
416
- ## Documentation
417
-
418
- Full documentation — language reference, all tags, triggers, control flow, services, modules, and production deployment — lives in the docs.
419
-
420
- **→ [Read the full documentation](./docs/README.md)**
421
-
422
- ## Support and security
423
-
424
- Use [GitHub Discussions](https://github.com/dali-benothmen/woml/discussions) for questions and [GitHub Issues](https://github.com/dali-benothmen/woml/issues) for reproducible bugs. Please report vulnerabilities privately according to the [security policy](SECURITY.md).
425
-
426
- ## Contributing
427
-
428
- Contributions are welcome — bug reports, feature ideas, docs, or a custom provider. See [CONTRIBUTING.md](./CONTRIBUTING.md) to get started.
782
+ ---
429
783
 
430
784
  ## License
431
785
 
432
- WOML is released under the [Apache License 2.0](./LICENSE).
786
+ Apache-2.0.