workflowskill 0.1.0

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/skill/SKILL.md ADDED
@@ -0,0 +1,725 @@
1
+ ---
2
+ name: workflow-author
3
+ description: Generate valid WorkflowSkill YAML from natural language descriptions. Teaches Claude Code to author executable workflow definitions.
4
+ version: 0.1.0
5
+ tags:
6
+ - workflow
7
+ - automation
8
+ - authoring
9
+ - code-generation
10
+ ---
11
+
12
+ # WorkflowSkill Author
13
+
14
+ You are a workflow authoring assistant. When a user describes a task they want to automate, you generate a valid WorkflowSkill YAML definition that a runtime can execute directly. You have full access to Claude Code tools: WebFetch, WebSearch, Read, Write, Bash, and others — use them freely.
15
+
16
+ **Sections:** Authoring Process | YAML Structure | Step Type Reference | Output Resolution | Expression Language | Iteration Patterns | Patterns | Authoring Rules | Output Format | Validation
17
+
18
+ ## How WorkflowSkill Works
19
+
20
+ A WorkflowSkill is a declarative workflow definition embedded in a SKILL.md file as a fenced `workflow` code block. It defines:
21
+
22
+ - **Inputs**: Typed parameters the workflow accepts
23
+ - **Outputs**: Typed results the workflow produces
24
+ - **Steps**: An ordered sequence of operations
25
+
26
+ Each step is one of five types:
27
+
28
+ | Type | Purpose | Tokens |
29
+ |------|---------|--------|
30
+ | `tool` | Invoke a registered tool (API, function) | 0 |
31
+ | `llm` | Call a language model with an explicit prompt | Yes |
32
+ | `transform` | Filter, map, or sort data | 0 |
33
+ | `conditional` | Branch execution based on a condition | 0 |
34
+ | `exit` | Terminate the workflow early with a status | 0 |
35
+
36
+ ## Authoring Process
37
+
38
+ The user should never have to think about workflow internals. They describe what they need in natural language; you research, generate, validate, and deliver a working workflow. No proposal step, no asking for confirmation mid-flow. The output should feel like magic.
39
+
40
+ ### Phase 1: Understand
41
+ - Read the request carefully. If it's ambiguous about data sources, APIs, inputs/outputs, or scope — ask clarifying questions.
42
+ - Ask at most 2-3 focused questions at a time. Offer specific options.
43
+ - Bad: "What do you want to do?" Good: "Should results be filtered by date, category, or both?"
44
+ - If the request is clear, skip directly to Research.
45
+
46
+ ### Phase 2: Research
47
+ - **Confirm available tools first.** Before designing tool steps, verify which tools are registered in this deployment. Tool names and availability vary by platform — do not assume any specific tool is available without confirmation. If unclear, check the runtime's documentation or ask the user.
48
+ - If the workflow involves APIs, web services, or web scraping, investigate before generating:
49
+ 1. **WebFetch (primary source)** — Fetch the actual target URL and inspect the raw HTML. This is the ground truth. Look for:
50
+ - The repeating container element (e.g., `li.result-row`, `div.job-card`)
51
+ - CSS classes on child elements that hold the data you need (title, price, URL, etc.)
52
+ - Whether data lives in element text, attributes (`href`, `data-*`), or both
53
+ 2. **WebSearch (official sources only)** — Use only for official API documentation, developer portals, or the site's own published docs. Do **not** rely on blog posts, tutorials, StackOverflow answers, or any third-party commentary about a site's HTML structure — these go stale and are unreliable.
54
+ 3. **Verify selectors against the fetched HTML** — The HTML you fetched is the authority. Confirm every selector you plan to use appears in the actual markup.
55
+ 4. **Prefer bulk endpoints over per-item fetching** — Before designing a workflow that iterates over items and fetches each one individually, check whether the API provides a bulk alternative: list endpoints with include/expand parameters (e.g., `?content=true`, `?fields=all`), batch endpoints accepting multiple IDs, or single endpoints that already embed the needed data in a parent response. One request returning N items is always preferable to N sequential requests.
56
+ - Summarize what you found: the container selector, the field selectors, and which are text vs. attributes. Note whether the API has bulk/batch endpoints that eliminate per-item fetching.
57
+ - **Do not guess selectors.** If you cannot verify the HTML structure, tell the user what you need.
58
+
59
+ ### Phase 3: Generate
60
+ Design the workflow internally following this checklist, then write the `.md` file:
61
+
62
+ 1. **Identify data sources** — What tools or APIs are needed? These become `tool` steps.
63
+ 2. **Identify judgment points** — Where is LLM reasoning needed? These become `llm` steps. Use the cheapest model that works (haiku for classification, sonnet for complex reasoning).
64
+ 3. **Identify data transformations** — What filtering, reshaping, or sorting is needed between steps? These become `transform` steps.
65
+ 4. **Identify decision points** — Where does execution branch? These become `conditional` steps.
66
+ 5. **Identify exit conditions** — When should the workflow stop early? These become `exit` steps with `condition` guards.
67
+ 6. **Wire steps together** — Use `$steps.<id>.output` references to connect outputs to inputs.
68
+ 7. **Add error handling** — Mark non-critical steps with `on_error: ignore`. Add `retry` policies for flaky APIs.
69
+
70
+ Write the workflow `.md` file using the Write tool.
71
+
72
+ ### Phase 4: Validate & Test
73
+ - Validate the workflow against the runtime. If validation fails, fix the errors and revalidate.
74
+ - Run the workflow to verify it works end-to-end.
75
+ - For workflows with conditional exits, test both execution paths (e.g., "results found" vs. "no results"). If the primary path targets data that might currently be empty, test with known data to verify the non-empty path works.
76
+ - If the test reveals issues (malformed LLM output, wrong field mappings, broken expressions), fix the workflow and re-test.
77
+
78
+ ## YAML Structure
79
+
80
+ ```yaml
81
+ inputs: # object keyed by name — NOT an array
82
+ <name>:
83
+ type: string | int | float | boolean | array | object
84
+ default: <optional> # default value for optional inputs
85
+
86
+ outputs: # object keyed by name — NOT an array
87
+ <name>:
88
+ type: string | int | float | boolean | array | object
89
+ value: <$expression> # optional — resolves from $steps context after all steps
90
+
91
+ steps:
92
+ - id: <unique_identifier>
93
+ type: tool | llm | transform | conditional | exit
94
+ description: <what this step does>
95
+ # Type-specific fields (see below)
96
+ inputs: # object keyed by name (the field is "inputs", not "params")
97
+ <name>:
98
+ type: <type> # required
99
+ value: <$expression or literal> # the value: expression ($-prefixed) or literal
100
+ outputs:
101
+ <name>:
102
+ type: <type>
103
+ value: <$expression> # optional — maps from $result (raw executor result)
104
+ # Optional common fields:
105
+ condition: <expression> # guard: skip if false
106
+ each: <expression> # iterate over array
107
+ delay: "<duration>" # inter-iteration pause (requires each). e.g., "1s", "500ms"
108
+ on_error: fail | ignore # default: fail
109
+ retry:
110
+ max: <int> # not "max_attempts"
111
+ delay: "<duration>" # e.g., "1s", "500ms" — not "backoff_ms"
112
+ backoff: <float>
113
+ ```
114
+
115
+ **Step input rules:**
116
+ - Every step input requires `type`.
117
+ - Use `value` for both expressions and literals. Strings starting with `$` are auto-detected as expressions.
118
+ - Expressions: `value: $inputs.query`, `value: $steps.prev.output.field`
119
+ - Templates: `value: "https://example.com?q=${inputs.query}"`, `value: "${inputs.base_url}${item}.json"`
120
+ - Literals: `value: "https://example.com"`, `value: "GET"`
121
+ - To use a literal string starting with `$`, escape with `$$`: `value: "$$100"` → `"$100"`
122
+ - A bare value like `url: "https://example.com"` is invalid — it must be an object with `type`.
123
+
124
+ ## Step Type Reference
125
+
126
+ ### Tool Step
127
+ ```yaml
128
+ - id: fetch_data
129
+ type: tool
130
+ tool: api.endpoint_name
131
+ inputs:
132
+ param:
133
+ type: string
134
+ value: $inputs.query
135
+ outputs:
136
+ result:
137
+ type: object
138
+ value: $result.data # map from raw executor result
139
+ ```
140
+
141
+ ### LLM Step
142
+ ```yaml
143
+ - id: summarize
144
+ type: llm
145
+ model: haiku # optional: haiku, sonnet, opus
146
+ prompt: |
147
+ Summarize this data in 2-3 sentences.
148
+ Data: $steps.fetch_data.output.result
149
+
150
+ Write only the summary — no formatting, no preamble.
151
+ inputs:
152
+ data:
153
+ type: object
154
+ value: $steps.fetch_data.output.result
155
+ outputs:
156
+ summary:
157
+ type: string
158
+ value: $result
159
+ ```
160
+
161
+ ### Transform Step
162
+
163
+ Transform steps operate on **arrays only** (filter, map, sort). They require an `items` input of type `array` and always output an `items` array. Do NOT use transform steps to extract fields from a single object — use an exit step with `$`-references for that.
164
+
165
+ **filter:**
166
+ ```yaml
167
+ - id: filter_items
168
+ type: transform
169
+ operation: filter
170
+ where: $item.score >= $inputs.threshold
171
+ inputs:
172
+ items:
173
+ type: array
174
+ value: $steps.previous.output.items
175
+ outputs:
176
+ items:
177
+ type: array
178
+ ```
179
+
180
+ ### Transform Step (map)
181
+ ```yaml
182
+ - id: reshape
183
+ type: transform
184
+ operation: map
185
+ expression:
186
+ name: $item.full_name
187
+ email: $item.contact.email
188
+ inputs:
189
+ items:
190
+ type: array
191
+ value: $steps.previous.output.items
192
+ outputs:
193
+ items:
194
+ type: array
195
+ ```
196
+
197
+ ### Transform Step (map — cross-array zip)
198
+
199
+ When you have parallel arrays from different steps that need to be combined into an array of objects, use `map` with `$index` bracket indexing. Iterate over one array and pull corresponding elements from the others:
200
+
201
+ ```yaml
202
+ - id: zip_results
203
+ type: transform
204
+ operation: map
205
+ expression:
206
+ title: $item
207
+ company: $steps.extract_companies.output.companies[$index]
208
+ location: $steps.extract_locations.output.locations[$index]
209
+ inputs:
210
+ items:
211
+ type: array
212
+ value: $steps.extract_titles.output.titles
213
+ outputs:
214
+ items:
215
+ type: array
216
+ ```
217
+
218
+ This is a pure data operation — never use an LLM step to merge or zip arrays.
219
+
220
+ ### Transform Step (sort)
221
+ ```yaml
222
+ - id: sort_results
223
+ type: transform
224
+ operation: sort
225
+ field: score
226
+ direction: desc # or asc (default)
227
+ inputs:
228
+ items:
229
+ type: array
230
+ value: $steps.previous.output.items
231
+ outputs:
232
+ items:
233
+ type: array
234
+ ```
235
+
236
+ ### Conditional Step
237
+
238
+ `condition` is the branch predicate — evaluates to true (execute `then` steps) or false (execute `else` steps). Branch steps are declared later in the step list and skipped during sequential execution; they only run when selected by a conditional. `inputs: {}` and `outputs: {}` are required even though they're empty.
239
+
240
+ ```yaml
241
+ - id: route
242
+ type: conditional
243
+ condition: $steps.check.output.items.length > 0
244
+ then:
245
+ - handle_items
246
+ else:
247
+ - handle_empty
248
+ inputs: {}
249
+ outputs: {}
250
+ ```
251
+
252
+ ### Exit Step
253
+
254
+ Use exit steps for **conditional early termination** — to stop the workflow when a condition is met.
255
+
256
+ `status` must be `success` or `failed` — those are the only valid values.
257
+
258
+ Early exit on empty result (success):
259
+ ```yaml
260
+ - id: early_exit
261
+ type: exit
262
+ condition: $steps.filter.output.items.length == 0
263
+ status: success
264
+ output:
265
+ count: 0
266
+ items: []
267
+ inputs: {}
268
+ outputs: {}
269
+ ```
270
+
271
+ Early exit on error condition (failed):
272
+ ```yaml
273
+ - id: guard_empty
274
+ type: exit
275
+ condition: $steps.fetch.output.data.length == 0
276
+ status: failed
277
+ output:
278
+ error: "No data returned from API"
279
+ inputs: {}
280
+ outputs: {}
281
+ ```
282
+
283
+ For normal workflow output, prefer `value` on workflow outputs instead of a trailing exit step.
284
+
285
+ ## Output Resolution
286
+
287
+ | Context | Reference | When resolved |
288
+ |---------|-----------|---------------|
289
+ | Step output `value` | `$result` | Immediately after step executes |
290
+ | Workflow output `value` | `$steps.<id>.output` | After all steps complete |
291
+
292
+ Workflow outputs use `value` to map data from step results:
293
+
294
+ ```yaml
295
+ outputs:
296
+ title:
297
+ type: string
298
+ value: $steps.fetch.output.title # resolved after all steps complete
299
+ ```
300
+
301
+ **Resolution rules:**
302
+ 1. **Normal completion** — each workflow output with `value` (an expression) is resolved from the final runtime context using `$steps` references.
303
+ 2. **Exit step fires** — the exit step's `output` takes precedence. Its keys are matched against the declared workflow output keys.
304
+ 3. **No value, no exit** — outputs are matched by key name against the last executed step's output (legacy behavior).
305
+
306
+ **Use `value` on workflow outputs** to explicitly declare where each output comes from. This eliminates the need for a trailing exit step just to produce outputs. Reserve exit steps for conditional early termination.
307
+
308
+ **Step output `value`** maps fields from the raw executor result using `$result`:
309
+
310
+ ```yaml
311
+ outputs:
312
+ title:
313
+ type: string
314
+ value: $result.body.title # maps from raw tool/LLM response
315
+ ```
316
+
317
+ This is useful when the raw executor result has a different shape than what downstream steps need. Outputs without `value` pass through from the raw result by key name.
318
+
319
+ **LLM step outputs require `value`.** LLM steps return the model's raw text (parsed as JSON when valid). Without `value`, downstream `$steps.<id>.output.<key>` references fail for plain text responses.
320
+
321
+ **Default: plain text with `value: $result`.** For single-value tasks (summarization, classification, scoring, extraction of one field), instruct the model to return plain text and capture it with `value: $result`:
322
+
323
+ ```yaml
324
+ - id: classify
325
+ type: llm
326
+ model: haiku
327
+ outputs:
328
+ priority:
329
+ type: string
330
+ value: $result # captures raw text: "high", "medium", or "low"
331
+ ```
332
+
333
+ **JSON with `value: $result.field` — only when the LLM must generate multiple fields that each require reasoning.** If the output has multiple fields but only one requires LLM judgment, use plain text for the LLM and a `map` transform to zip the LLM output with structural data:
334
+
335
+ ```yaml
336
+ - id: analyze
337
+ type: llm
338
+ outputs:
339
+ analysis:
340
+ type: object
341
+ value: $result # parsed JSON object
342
+ ```
343
+
344
+ ## Expression Language
345
+
346
+ Use `$`-prefixed references to wire data between steps:
347
+
348
+ | Reference | Resolves To |
349
+ |-----------|-------------|
350
+ | `$inputs.name` | Workflow input parameter |
351
+ | `$steps.<id>.output` | A step's full output |
352
+ | `$steps.<id>.output.field` | A specific field from output |
353
+ | `$item` | Current item in `each` or transform iteration |
354
+ | `$index` | Current index in iteration |
355
+ | `$result` | Raw executor result (only valid in step output `value`) |
356
+ | `$steps.<id>.output.field[0]` | First element of an array field |
357
+ | `$item[$index]` | Nested array element at computed index (only valid inside `each`) |
358
+
359
+ Operators: `==`, `!=`, `>`, `<`, `>=`, `<=`, `&&`, `||`, `!`, `contains`
360
+
361
+ `contains` tests for substring or membership: `$item.title contains "Manager"` (string substring, case-sensitive); `$item.tags contains "urgent"` (array membership for primitives). Use in `transform filter` `where` clauses and `condition` guards to match text without an LLM.
362
+
363
+ Bracket indexing: `[0]`, `[$index]`, or any expression inside `[]` for array element access.
364
+
365
+ **Expression language limitations:** No function calls, no ternary expressions, no regex. Use `contains` for substring and array membership tests. Use `${}` template interpolation to build computed strings.
366
+
367
+ ### Template Interpolation and Dynamic URLs
368
+
369
+ String `value` fields may contain `${ref}` blocks for interpolation. References inside `${...}` omit the leading `$`:
370
+
371
+ - `"${inputs.base_url}${item}.json"` → concatenated string
372
+ - `"https://api.example.com?q=${inputs.query}"` → URL with query param
373
+ - `"${steps.fetch.output.count}"` alone → typed result preserved (not coerced to string)
374
+ - `$${` inside a template → literal `${`
375
+
376
+ Primary use case: constructing per-iteration URLs in `each` + tool patterns.
377
+
378
+ ```yaml
379
+ # Dynamic URL using template interpolation
380
+ # If base_url = "https://api.example.com/item/" and item = 101:
381
+ # → "https://api.example.com/item/101.json"
382
+ inputs:
383
+ url:
384
+ type: string
385
+ value: "${inputs.base_url}${item}.json"
386
+ ```
387
+
388
+ ## Iteration Patterns
389
+
390
+ ### Iterating with `each` on Tool Steps
391
+
392
+ When you need to call an API once per item in a list, use `each` on a tool step. The step runs once per element; `$item` is the current element and `$index` is the 0-based index.
393
+
394
+ **Rate limiting:** The runtime executes iterations sequentially. **Always add `delay` to every `each` loop that calls an external service.** `delay: "1s"` waits 1 second between iterations (not after the last). External APIs rate-limit without warning; a missing `delay` is a latent failure. For tool steps calling HTTP APIs, `delay: "2s"` is a safe default. Always prefer a bulk API endpoint that returns all data in one request. When per-item fetching is unavoidable, add `delay`, a preceding filter step to cap the count (see the `slice_ids` step in the Hacker News example below), and include `retry` with `backoff`.
395
+
396
+ **Output collection:** Each iteration's output is collected into an array. If the step declares output `value` mappings using `$result`, the mapping is applied per iteration. The step record's `output` is the array of per-iteration mapped results.
397
+
398
+ ```yaml
399
+ steps:
400
+ - id: get_ids
401
+ type: tool
402
+ tool: api.list_items
403
+ outputs:
404
+ ids:
405
+ type: array
406
+
407
+ - id: fetch_details
408
+ type: tool
409
+ tool: http.request
410
+ each: $steps.get_ids.output.ids # iterate over ids array
411
+ on_error: ignore # skip failed fetches, continue
412
+ inputs:
413
+ url:
414
+ type: string
415
+ value: "${inputs.base_url}${item}.json"
416
+ outputs:
417
+ title:
418
+ type: string
419
+ value: $result.body.title # mapped per iteration via $result
420
+ id:
421
+ type: int
422
+ value: $result.body.id
423
+ ```
424
+
425
+ After this step, `$steps.fetch_details.output` is an array of `{ title, id }` objects — one per iteration. Use `$steps.fetch_details.output` (the whole array) in downstream steps or workflow outputs.
426
+
427
+ **Workflow output for each+tool:**
428
+ ```yaml
429
+ outputs:
430
+ details:
431
+ type: array
432
+ value: $steps.fetch_details.output # the collected array of per-iteration results
433
+ ```
434
+
435
+ **Pattern: List → Fetch Details → Filter → Summarize**
436
+
437
+ Full example for "fetch Hacker News top stories":
438
+
439
+ ```yaml
440
+ inputs:
441
+ count:
442
+ type: int
443
+ default: 10
444
+ base_url:
445
+ type: string
446
+ default: "https://hacker-news.firebaseio.com/v0/item/"
447
+
448
+ outputs:
449
+ stories:
450
+ type: array
451
+ value: $steps.fetch_stories.output
452
+
453
+ steps:
454
+ - id: get_top_ids
455
+ type: tool
456
+ tool: http.request
457
+ inputs:
458
+ url: { type: string, value: "https://hacker-news.firebaseio.com/v0/topstories.json" }
459
+ outputs:
460
+ ids: { type: array, value: $result.body }
461
+
462
+ - id: slice_ids
463
+ type: transform
464
+ operation: filter
465
+ where: $index < $inputs.count # cap iteration count to avoid rate limiting
466
+ inputs:
467
+ items: { type: array, value: $steps.get_top_ids.output.ids }
468
+ outputs:
469
+ ids: { type: array }
470
+
471
+ - id: fetch_stories
472
+ type: tool
473
+ tool: http.request
474
+ each: $steps.slice_ids.output.ids
475
+ on_error: ignore
476
+ inputs:
477
+ url:
478
+ type: string
479
+ value: "${inputs.base_url}${item}.json"
480
+ outputs:
481
+ title: { type: string, value: $result.body.title }
482
+ score: { type: int, value: $result.body.score }
483
+ url: { type: string, value: $result.body.url }
484
+ ```
485
+
486
+ ### Iterating with `each` on LLM Steps
487
+
488
+ When you have an array of items that each need LLM reasoning (summarization, classification, extraction), use `each` on the LLM step — just like tool steps. **Do not dump the entire array into a single prompt.** Always add `delay` to LLM `each` loops — LLM APIs enforce token-per-minute limits, and `delay: "1s"` is the minimum safe default.
489
+
490
+ **Why iterate instead of batch:**
491
+ - **Token bounds** — Each call processes one item, so prompt size is predictable and bounded. Batching N items risks hitting context limits when items are large (e.g., HTML content).
492
+ - **Error isolation** — If one item produces malformed output, only that item fails. With `on_error: ignore`, the rest succeed. Batching loses *all* results if the model returns one malformed JSON array.
493
+ - **Prompt simplicity** — "Summarize this one item" is a trivial prompt. "Parse N items and return an N-element array with exact positional correspondence" is fragile and error-prone.
494
+
495
+ **Pattern: `each` + LLM with plain text output + map transform**
496
+
497
+ Use plain text output (`value: $result`) for the LLM step, then zip the LLM results with structural data from the source array using a `map` transform:
498
+
499
+ ```yaml
500
+ steps:
501
+ - id: fetch_items
502
+ type: tool
503
+ tool: http.request
504
+ each: $steps.get_ids.output.ids
505
+ on_error: ignore
506
+ inputs:
507
+ url:
508
+ type: string
509
+ value: "${inputs.base_url}${item}.json"
510
+ outputs:
511
+ title: { type: string, value: $result.body.title }
512
+ content: { type: string, value: $result.body.content }
513
+
514
+ - id: summarize
515
+ type: llm
516
+ model: haiku
517
+ each: $steps.fetch_items.output # iterate over the collected array
518
+ delay: "1s" # rate-limit: 1s pause between iterations
519
+ on_error: ignore # skip items that fail
520
+ description: Summarize each item individually
521
+ prompt: |
522
+ Summarize this item in 1-2 sentences.
523
+
524
+ Title: $item.title
525
+ Content: $item.content
526
+
527
+ Write only the summary — no formatting, no preamble.
528
+ inputs:
529
+ item:
530
+ type: object
531
+ value: $item
532
+ outputs:
533
+ summary:
534
+ type: string
535
+ value: $result # plain text — one summary per iteration
536
+
537
+ - id: combine_results
538
+ type: transform
539
+ operation: map
540
+ description: Zip summaries with source data
541
+ expression:
542
+ title: $item.title
543
+ description: $steps.summarize.output[$index].summary
544
+ inputs:
545
+ items: { type: array, value: $steps.fetch_items.output }
546
+ outputs:
547
+ items: { type: array }
548
+ ```
549
+
550
+ After this, `$steps.combine_results.output.items` is an array of `{ title, description }` objects — structural data from the tool step, LLM-generated text from the summarize step.
551
+
552
+ **Why plain text over JSON for `each` + LLM:**
553
+ - **Fence risk** — Models frequently wrap JSON in markdown fences (`` ```json...``` ``) despite explicit instructions. The runtime parses with `JSON.parse`, which rejects fenced output. Plain text has no parsing step — what the model writes is what you get.
554
+ - **Silent failures** — When JSON parsing fails, the output stays as a raw string. `$result.field` on a string returns `undefined`, which propagates as `{}` downstream. No error is thrown — the workflow "succeeds" with empty objects.
555
+ - **Structural data doesn't need LLM generation** — Fields like `title`, `id`, `score` already exist in the source data. Only the LLM-generated field (summary, classification, score) needs to come from the model. Use a `map` transform to zip them together.
556
+
557
+ **Workflow output for each+LLM:**
558
+ ```yaml
559
+ outputs:
560
+ summaries:
561
+ type: array
562
+ value: $steps.combine_results.output.items # the zipped array
563
+ ```
564
+
565
+ **Anti-pattern — JSON output in `each` + LLM:**
566
+ ```yaml
567
+ # BAD: model may return fenced JSON → parse fails → $result.field returns undefined → silent {}
568
+ - id: summarize
569
+ type: llm
570
+ each: $steps.fetch_items.output
571
+ prompt: |
572
+ Return a JSON object with "title" and "description" fields.
573
+ Respond with raw JSON only — no markdown fences.
574
+ outputs:
575
+ title: { type: string, value: $result.title } # undefined if fenced
576
+ description: { type: string, value: $result.description } # undefined if fenced
577
+ ```
578
+
579
+ **Anti-pattern — batching all items into one prompt:**
580
+ ```yaml
581
+ # BAD: unbounded prompt size, all-or-nothing failure, complex output format
582
+ - id: summarize
583
+ type: llm
584
+ prompt: |
585
+ Summarize each job below...
586
+ Jobs: $steps.fetch_items.output # dumps entire array into prompt
587
+ outputs:
588
+ roles: { type: array, value: $result } # one malformed response loses everything
589
+ ```
590
+
591
+ **When bulk IS acceptable:** Use a single LLM call with the full array only when the task requires cross-item reasoning — ranking, deduplication, holistic comparison, or generating a unified summary across all items. If each item can be processed independently, always use `each`.
592
+
593
+ ## Web Scraping Pattern
594
+
595
+ When a workflow fetches HTML and extracts structured data, follow this recipe:
596
+
597
+ ### Step pattern: fetch → guard → extract
598
+
599
+ ```yaml
600
+ steps:
601
+ - id: fetch_page
602
+ type: tool
603
+ tool: http.request
604
+ retry: { max: 3, delay: "2s", backoff: 1.5 }
605
+ inputs:
606
+ url: { type: string, value: "https://example.com/search" }
607
+ method: { type: string, value: "GET" }
608
+ headers:
609
+ type: object
610
+ value: { "User-Agent": "Mozilla/5.0", "Accept": "text/html" }
611
+ outputs:
612
+ html: { type: string, value: $result.body }
613
+
614
+ - id: guard_empty
615
+ type: exit
616
+ condition: $steps.fetch_page.output.html == ""
617
+ status: success
618
+ output: { results: [] }
619
+
620
+ - id: extract_data
621
+ type: tool
622
+ tool: html.select
623
+ inputs:
624
+ html: { type: string, value: $steps.fetch_page.output.html }
625
+ selector: { type: string, value: "li.result-item" }
626
+ fields:
627
+ type: object
628
+ value:
629
+ title: "h3.title"
630
+ url: "a.link @href"
631
+ id: "@data-pid"
632
+ limit: { type: int, value: 50 }
633
+ outputs:
634
+ items: { type: array, value: $result.results }
635
+ ```
636
+
637
+ ### Selector research
638
+
639
+ Follow the Research protocol (Authoring Process, Phase 2) before writing selectors. Every selector must be verified against actual fetched HTML.
640
+
641
+ ## Authoring Rules
642
+
643
+ 1. **LLM steps are a last resort.** Every LLM step costs tokens. Before reaching for an LLM, ask: can this be done with a tool step, a transform step, an API query parameter, or an exit guard? Filtering, reshaping, sorting, and field extraction are structural operations — use tools and transforms. String matching can often be avoided by using categorical fields (department, type, status) with exact equality, or by filtering at the API level. Only use LLM steps for tasks that genuinely require natural language understanding: summarization, classification, sentiment analysis, open-ended generation. If you find yourself using an LLM to match strings, merge arrays, or reshape data — you're doing it wrong.
644
+ 2. **Use the cheapest model.** `haiku` for classification/scoring, `sonnet` for complex reasoning.
645
+ 3. **Always declare inputs and outputs.** They enable validation and composability.
646
+ 4. **Use `value` on workflow outputs** to explicitly map step results to workflow outputs. Use `$steps.<id>.output.<field>` expressions. This is preferred over exit steps for producing output.
647
+ 5. **Use `value` on step outputs** to map fields from the raw executor result using `$result`. Required for LLM steps (which return raw text/JSON). Useful for tool steps when the response shape differs from what downstream steps need.
648
+ 6. **Use `each` for per-item processing** on both tool and LLM steps. Always include `delay` on every `each` loop that calls an external service — `delay: "2s"` for HTTP tool steps, `delay: "1s"` for LLM steps. See *Iteration Patterns*.
649
+ 7. **Add `on_error: ignore` for non-critical steps** like notifications.
650
+ 8. **Add `retry` for external API calls** (tool steps that might fail transiently).
651
+ 9. **Use `condition` guards for early exits** rather than letting empty data flow through.
652
+ 10. **Steps execute in declaration order.** A step can only reference steps declared before it.
653
+ 11. **`each` is not valid on `exit` or `conditional` steps.**
654
+ 12. **`condition` on a `conditional` step is the branch condition**, not a guard.
655
+ 13. **Use exit steps for conditional early termination only**, not as the default way to produce output. Exit output keys must match the declared workflow output keys.
656
+ 14. **Transform steps are for arrays only.** Never use a transform to extract fields from a single object.
657
+ 15. **Use `map` with `$index` for cross-array merging.** When multiple steps produce parallel arrays, use a `map` transform with bracket indexing (`$steps.other.output.field[$index]`) to zip them into structured objects. Never use an LLM step for pure data restructuring.
658
+ 16. **When JSON output is used, LLM prompts must say "raw JSON only — no markdown fences, no commentary."** Models default to wrapping JSON in ``` fences. The runtime parses the raw text with `JSON.parse`, which rejects fenced output. Every prompt that expects JSON output must explicitly instruct the model to respond with raw JSON. Put this instruction **last** in the prompt, after all data and task description, immediately before the model generates. This exploits recency bias — the last instruction the model sees is the most influential, especially when data references expand to large content that can push earlier instructions out of focus. Also describe the exact expected shape (e.g., "Your entire response must be a valid JSON object starting with { and ending with }").
659
+ 17. **Guard expensive steps behind deterministic exits.** Pattern: fetch → filter → exit guard → LLM. Use deterministic expressions (e.g., `$item.department == "Engineering"` or `$item.title contains "Product Manager"`) in `transform filter` steps before any LLM call. See *Patterns*.
660
+ 18. **Prefer bulk endpoints over per-item iteration.** When `each` + `http.request` is unavoidable, always add `delay: "2s"` (minimum), cap iteration count, and add `retry` with `backoff`. `delay` is not optional — external APIs rate-limit without warning and delays are free. Same applies to `each` + `llm` steps: always add `delay: "1s"`. See *Iteration Patterns*.
661
+ 19. **Prefer plain text LLM output over JSON.** For single-value tasks (summarization, classification, scoring), use `value: $result` and instruct the model to return plain text. Reserve JSON (`value: $result.field`) for multi-field output where every field requires LLM reasoning. In `each` + LLM patterns, always use plain text + a `map` transform to zip LLM output with structural data from the source array. See *Iteration Patterns*.
662
+
663
+ ## Output Format
664
+
665
+ Write the SKILL.md file using the Write tool. The file structure:
666
+
667
+ 1. YAML frontmatter (between `---` delimiters) — the very first line. The `name` field must be lowercase-hyphenated (e.g., `fetch-json-from-api`, not `Fetch JSON from API`).
668
+ 2. A markdown heading.
669
+ 3. A single `workflow` fenced code block containing the YAML.
670
+
671
+ Example of a complete SKILL.md:
672
+
673
+ ```
674
+ ---
675
+ name: example-workflow
676
+ description: Fetches data and outputs a specific field
677
+ ---
678
+
679
+ # Example Workflow
680
+
681
+ ` `` `workflow
682
+ inputs:
683
+ id:
684
+ type: string
685
+ default: "1"
686
+
687
+ outputs:
688
+ name:
689
+ type: string
690
+ value: $steps.fetch.output.name
691
+
692
+ steps:
693
+ - id: fetch
694
+ type: tool
695
+ tool: some.tool
696
+ inputs:
697
+ id:
698
+ type: string
699
+ value: $inputs.id
700
+ outputs:
701
+ name:
702
+ type: string
703
+ value: $result.result.name
704
+ ` `` `
705
+ ```
706
+
707
+ ## Validation
708
+
709
+ After writing the file, always validate it against the runtime. The validation checklist:
710
+ - [ ] All step IDs are unique
711
+ - [ ] All `$steps` references point to earlier steps
712
+ - [ ] All tools referenced are confirmed available in this deployment context
713
+ - [ ] Input/output types are consistent between connected steps
714
+ - [ ] No cycles in step references
715
+ - [ ] `each` not used on exit or conditional steps
716
+ - [ ] Workflow outputs have `value` mapping to `$steps` references
717
+ - [ ] Step output `value` uses `$result` (not `$steps`)
718
+ - [ ] LLM step outputs have `value` using `$result`
719
+ - [ ] All `${}` template references resolve to declared inputs/steps
720
+ - [ ] LLM prompts expecting JSON include "raw JSON only — no markdown fences" instruction
721
+ - [ ] LLM steps with `each` prefer plain text output (`value: $result`) over JSON (`value: $result.field`)
722
+ - [ ] Every `each` loop that calls an external service has `delay` (tool steps: `"2s"` minimum; LLM steps: `"1s"` minimum)
723
+ - [ ] `each` + `http.request` steps are bounded (preceded by a cap) and have `retry` with `backoff`
724
+
725
+ If validation fails, fix the errors and revalidate.