woml-cli 1.0.2 → 1.0.5

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,48 +1,686 @@
1
- # WOML
1
+ # WOML: Workflow Orchestration Markup Language
2
2
 
3
- WOML is a markup-first language and durable runtime for workflow automation.
4
- Define triggers, steps, control flow, approvals, lifecycle behavior, and runtime
5
- policy in readable `.woml` files; write ordinary JavaScript inside `<script>`.
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
+ ---
6
8
 
7
9
  ## Install
8
10
 
9
- WOML requires Bun 1.3.14 or later:
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:
22
+
23
+ ```bash
24
+ pnpm add -g woml-cli
25
+ ```
26
+
27
+ Verify:
10
28
 
11
29
  ```bash
12
- bun add --global woml-cli
13
30
  woml --version
14
31
  ```
15
32
 
16
- The one `woml` package contains the CLI and compiler and selects the native Rust
17
- engine for the current supported platform. Users do not install a compiler or
18
- native package separately.
33
+ **Requirements:** macOS (x64, arm64), Linux (x64, glibc), or Windows (x64, arm64). No database to set up — the default state store is bundled.
34
+
35
+ ---
36
+
37
+ ## Document structure
38
+
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.
40
+
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
+ ```
54
+
55
+ Runtime bindings available inside every `<script>`:
56
+
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
62
+
63
+ Scripts return JSON-compatible values. The Rust engine records every outcome durably.
64
+
65
+ ---
66
+
67
+ ## Triggers
68
+
69
+ Triggers decide **when** a workflow runs. Place one or more inside `<triggers>`.
70
+
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" />
77
+ ```
78
+
79
+ ### `<webhook>` — accept HTTP requests
80
+
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>
93
+ ```
94
+
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.
97
+
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" />
107
+ ```
108
+
109
+ `timezone` defaults to UTC. `on-missed` chooses `skip` or `run-once` after a restart.
110
+
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}}" />
181
+ ```
182
+
183
+ `phone-number-id` is Meta's durable Phone Number ID, not the display phone number.
184
+
185
+ ---
186
+
187
+ ## Steps
188
+
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.
19
310
 
20
- ## Quick start
311
+ ---
21
312
 
22
- Save this as `hello.woml`:
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.
397
+
398
+ ```xml
399
+ <woml>
400
+ <workflow id="organize" name="Organize a folder by file type" version="1.0.0">
401
+ <triggers><manual id="start" /></triggers>
402
+
403
+ <steps>
404
+ <step id="scan">
405
+ <script>
406
+ const { promises: fs } = await import('fs');
407
+ const path = await import('path');
408
+ const folder = context.payload.path ?? '.';
409
+ const entries = await fs.readdir(folder, { withFileTypes: true });
410
+ return {
411
+ folder,
412
+ files: entries
413
+ .filter(e => e.isFile())
414
+ .map(e => ({ name: e.name, ext: path.extname(e.name).toLowerCase() })),
415
+ };
416
+ </script>
417
+ </step>
418
+
419
+ <parallel id="moveAll" concurrency="4">
420
+ <step id="moveImages">
421
+ <script>
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 };
432
+ </script>
433
+ </step>
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>
477
+
478
+ <step id="summary">
479
+ <script>
480
+ return {
481
+ message: `Organized ${context.steps.scan.files.length} file(s) into Images/, Docs/, Videos/, Archives/.`
482
+ };
483
+ </script>
484
+ </step>
485
+ </steps>
486
+ </workflow>
487
+ </woml>
488
+ ```
489
+
490
+ ```bash
491
+ woml run organize.woml --payload '{"path":"/path/to/Downloads"}'
492
+ ```
493
+
494
+ ---
495
+
496
+ ### AI-powered — classify Slack messages and route them to the right channel
497
+
498
+ Send every incoming Slack message to an LLM, classify intent, and forward to a dedicated channel.
499
+
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>
510
+
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
23
587
 
24
588
  ```xml
25
589
  <woml>
26
- <workflow
27
- id="hello"
28
- name="Hello WOML"
29
- description="Build a greeting from two durable steps."
30
- version="1.0.0"
31
- >
590
+ <workflow id="order-guard" version="1.0.0">
32
591
  <triggers>
33
- <manual id="start" />
592
+ <webhook id="order"
593
+ path="/webhooks/orders"
594
+ method="POST"
595
+ auth="bearer"
596
+ secret="{{secrets.ORDER_WEBHOOK_TOKEN}}">
597
+ <schema>
598
+ {
599
+ "type": "object",
600
+ "required": ["orderId", "total", "customerId"],
601
+ "properties": {
602
+ "orderId": { "type": "string" },
603
+ "total": { "type": "number" },
604
+ "customerId": { "type": "string" }
605
+ }
606
+ }
607
+ </schema>
608
+ </webhook>
34
609
  </triggers>
35
610
 
36
611
  <steps>
37
- <step id="prepare" name="Prepare greeting">
612
+ <step id="risk">
38
613
  <script>
39
- return { name: context.payload.name ?? "World" };
614
+ const customer = await services.http.request({
615
+ method: 'GET',
616
+ url: `https://internal.api/customers/${context.payload.customerId}`,
617
+ timeoutMs: 5000
618
+ });
619
+ return {
620
+ flagged: context.payload.total > 10000 || customer.body.disputes > 0
621
+ };
40
622
  </script>
41
623
  </step>
42
624
 
43
- <step id="greet" name="Build message">
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>
649
+ </steps>
650
+ </workflow>
651
+ </woml>
652
+ ```
653
+
654
+ ---
655
+
656
+ ### Schedule — daily sales report at 9am weekdays
657
+
658
+ ```xml
659
+ <woml>
660
+ <workflow id="daily-report" version="1.0.0">
661
+ <triggers>
662
+ <schedule id="weekdays" cron="0 9 * * MON-FRI" timezone="UTC" />
663
+ </triggers>
664
+
665
+ <steps>
666
+ <step id="totals">
667
+ <script>
668
+ const rows = await services.database.query({
669
+ sql: "SELECT COUNT(*) AS orders, COALESCE(SUM(total), 0) AS revenue FROM orders WHERE created_at >= date('now', '-1 day')",
670
+ parameters: []
671
+ });
672
+ return rows[0];
673
+ </script>
674
+ </step>
675
+
676
+ <step id="publish">
44
677
  <script>
45
- return { message: `Hello ${context.steps.prepare.name}` };
678
+ const { orders, revenue } = context.steps.totals;
679
+ await services.slack.send({
680
+ channel: '#sales',
681
+ text: `Daily report — ${orders} orders, $${revenue.toFixed(2)} revenue.`
682
+ });
683
+ return { sent: true };
46
684
  </script>
47
685
  </step>
48
686
  </steps>
@@ -50,75 +688,99 @@ Save this as `hello.woml`:
50
688
  </woml>
51
689
  ```
52
690
 
53
- Then run:
691
+ ---
54
692
 
55
- ```bash
56
- woml check hello.woml
57
- woml run hello.woml
693
+ ### Telegram — answer mentions in your team chat
694
+
695
+ ```xml
696
+ <woml>
697
+ <workflow id="telegram-echo" version="1.0.0">
698
+ <triggers>
699
+ <telegram id="incoming"
700
+ events="message"
701
+ bot-token="{{secrets.TELEGRAM_BOT_TOKEN}}" />
702
+ </triggers>
703
+
704
+ <steps>
705
+ <step id="reply">
706
+ <script>
707
+ await services.messaging.send({
708
+ channel: 'telegram',
709
+ conversationId: context.payload.conversationId,
710
+ text: `You said: ${context.payload.text}`
711
+ });
712
+ return { ok: true };
713
+ </script>
714
+ </step>
715
+ </steps>
716
+ </workflow>
717
+ </woml>
718
+ ```
719
+
720
+ ---
721
+
722
+ ### Event — send a confirmation email when another workflow emits `order.created`
723
+
724
+ ```xml
725
+ <woml>
726
+ <workflow id="order-confirmation" version="1.0.0">
727
+ <triggers>
728
+ <event id="created"
729
+ name="order.created"
730
+ secret="{{secrets.EVENT_CONTROL_TOKEN}}">
731
+ <schema>
732
+ {
733
+ "type": "object",
734
+ "required": ["orderId", "email"],
735
+ "properties": {
736
+ "orderId": { "type": "string" },
737
+ "email": { "type": "string" }
738
+ }
739
+ }
740
+ </schema>
741
+ </event>
742
+ </triggers>
743
+
744
+ <steps>
745
+ <step id="confirm">
746
+ <script>
747
+ await services.http.request({
748
+ method: 'POST',
749
+ url: 'https://api.emailprovider.com/v1/send',
750
+ headers: { authorization: `Bearer ${secrets.EMAIL_API_KEY}` },
751
+ body: {
752
+ to: context.payload.email,
753
+ template: 'order-confirmation',
754
+ data: { orderId: context.payload.orderId }
755
+ },
756
+ timeoutMs: 5000
757
+ });
758
+ return { sentTo: context.payload.email };
759
+ </script>
760
+ </step>
761
+ </steps>
762
+ </workflow>
763
+ </woml>
58
764
  ```
59
765
 
60
- Press Enter to start a run. The automation remains active for the next trigger;
61
- press Ctrl+C to stop it. `woml test hello.woml` is the one-shot form for tests
62
- and CI.
766
+ ---
63
767
 
64
- ## Main commands
768
+ ## Common commands
65
769
 
66
770
  ```bash
67
- woml --help
68
- woml check <workflow-or-directory>...
69
- woml run <workflow-or-directory>...
70
- woml run workflows/ --background
71
- woml inspect
72
- woml list
73
- woml get <run-id>
74
- woml cancel <run-id>
75
- woml <run-id-or-workflow-id> --logs
76
- woml secrets set <NAME>
77
- woml backup <directory>
78
- woml prune --before 30d --dry-run
79
- ```
80
-
81
- `woml run` activates multiple files and directories atomically. This makes
82
- call-only workflows available to `services.workflows.call()` and
83
- `services.workflows.start()` in the same runtime. Webhook startup prints its URL
84
- and a generated `curl`; manual triggers print the keyboard instruction.
85
-
86
- ## Runtime bindings
87
-
88
- Inside scripts:
89
-
90
- - `context.payload` is input from the trigger or parent workflow;
91
- - `context.steps.<id>` is a visible successful step/control-flow result;
92
- - `services` exposes supervised capabilities and imported modules;
93
- - `secrets.NAME` resolves only declared secrets; and
94
- - `attempt` describes the current durable retry attempt.
95
-
96
- Return JSON-compatible data for downstream steps. WOML records durable events
97
- before advancing the compiled graph and fails closed rather than replaying an
98
- ambiguous external effect.
99
-
100
- ## Capabilities
101
-
102
- WOML v1 includes manual and production triggers, retries, choices, switches,
103
- parallel groups, forked branches, approvals, lifecycle hooks, runtime policies,
104
- HTTP, SQL databases, storage, cache, events, durable state, local modules,
105
- reusable steps/providers, communication adapters, workflow call/start, run
106
- inspection, backup, recovery, and retention.
107
-
108
- ## Documentation
109
-
110
- - [Getting started](https://github.com/dali-benothmen/woml/blob/master/docs/getting-started.md)
111
- - [Language reference](https://github.com/dali-benothmen/woml/blob/master/docs/language-reference.md)
112
- - [CLI reference](https://github.com/dali-benothmen/woml/blob/master/docs/cli-reference.md)
113
- - [Examples](https://github.com/dali-benothmen/woml/tree/master/examples)
114
- - [Production deployment](https://github.com/dali-benothmen/woml/blob/master/docs/woml-production-deployment.md)
115
- - [VS Code extension](https://github.com/dali-benothmen/woml/tree/master/woml-vscode)
116
-
117
- For support, use
118
- [GitHub Discussions](https://github.com/dali-benothmen/woml/discussions) or
119
- open a reproducible [issue](https://github.com/dali-benothmen/woml/issues).
120
- Report vulnerabilities privately according to the
121
- [security policy](https://github.com/dali-benothmen/woml/blob/master/SECURITY.md).
122
-
123
- WOML is licensed under the
124
- [Apache License 2.0](https://github.com/dali-benothmen/woml/blob/master/LICENSE).
771
+ woml check workflows/ # Validate workflows without running them
772
+ woml run workflows/ # Run in the foreground (Ctrl+C to stop)
773
+ woml run workflows/ --background # Run in the background, survives Ctrl+C
774
+ woml inspect # Show the current state of all runs
775
+ woml list # List known workflows and recent runs
776
+ woml get run_... # Print the full event history of a run
777
+ woml cancel run_... # Cancel a running or pending run
778
+ woml backup backups/latest # Snapshot the durable state store to a file
779
+ woml prune --before 30d --dry-run # Preview which old runs would be purged
780
+ ```
781
+
782
+ ---
783
+
784
+ ## License
785
+
786
+ Apache-2.0.