Skip to main content
T2 fan-out · personal / research: for_each fans one nika:fetch with mode: metadata per URL — the page’s OG/meta head as typed data, not a scrape — with retry: for transient blips and on_error: recover: so a dead link degrades to a { dead: true } row instead of failing the batch. nika:jq renders the triage table; nika:write persists it.

The job

Every bookmark pile has the same two questions: what is this, and is it even alive? Answering them by hand means opening every tab. This workflow answers both in one run — title/description/site from each page’s own metadata, dead links marked as dead — and leaves a table you can sort, grep, and prune from.

The shape

The file

t2-bookmark-triage.nika.yaml
nika: v1
workflow: bookmark-triage
description: "URL list → per-page metadata fan-out (dead links survive) → one markdown triage table"

vars:
  bookmarks:
    type: array
    default:
      - "https://example.com"
      - "https://github.com/supernovae-st/nika"
      - "https://this-domain-does-not-exist-0000.invalid" # the dead one · the recover path IS the demo
    description: "The saved-links pile to triage"

permits:
  exec: false
  net:
    http: ["example.com", "github.com", "this-domain-does-not-exist-0000.invalid"]
  fs:
    write: ["out/**"]
  tools: ["nika:fetch", "nika:jq", "nika:write"]

tasks:
  # The fan-out · one metadata fetch per URL · a dead link recovers to a
  # marker object (the batch NEVER dies at bookmark 14).
  - id: pages
    for_each: ${{ vars.bookmarks }}
    max_parallel: 3
    fail_fast: false
    retry: { max_attempts: 2, backoff_ms: 500 } # a transient blip retries BEFORE the dead verdict
    on_error:
      recover: { dead: true } # a YAML value, not a JSON string — the marker row
    invoke:
      tool: "nika:fetch"
      args:
        url: "${{ item }}"
        mode: metadata

  # The fan-in · zip results back to their URLs · split live from dead.
  - id: table
    depends_on: [pages]
    invoke:
      tool: "nika:jq"
      args:
        input:
          urls: ${{ vars.bookmarks }}
          pages: ${{ tasks.pages.output }}
        expression: >-
          def esc: tostring | gsub("\\|"; "\\|") | gsub("\n"; " ");
          ([.urls, .pages] | transpose | map({ url: .[0], page: .[1] })) as $rows |
          {
            live: ([$rows[] | select(.page.dead != true) |
              "| \(.url) | \(.page.title // "—" | esc) | \((.page.description // "—" | esc) | .[0:80]) |"] | join("\n")),
            dead: ([$rows[] | select(.page.dead == true) | "| \(.url) | DEAD | link rot |"] | join("\n"))
          }

  - id: report
    depends_on: [table]
    invoke:
      tool: "nika:write"
      args:
        path: out/bookmarks.md
        create_dirs: true
        content: |
          # Bookmark triage

          | url | title | description |
          | --- | --- | --- |
          ${{ tasks.table.output.live }}
          ${{ tasks.table.output.dead }}

          *Generated by [nika](https://nika.sh) — dead links survived the batch (`on_error: recover:`).*

outputs:
  table:
    value: ${{ tasks.table.output }}
    type: object
    description: "Live rows + dead rows · the triage split"

The resilience ladder, in order

  1. retry: { max_attempts: 2, backoff_ms: 500 } — a transient blip gets a second chance BEFORE any verdict.
  2. on_error: recover: — a link that still fails becomes { dead: true } and the batch keeps walking. recover: takes a YAML value, not a JSON string: quote it and jq downstream sees an unindexable string instead of an object.
  3. The table renders both worlds — the jq step escapes pipes and newlines from page-controlled metadata (a | in someone’s OG title must not break your markdown table), and dead rows print as DEAD instead of vanishing.
The quiet lesson: mode: metadata fetches the page’s head, typed — when all you need is identity, don’t ask for the body.

Run it

git clone https://github.com/supernovae-st/nika-spec && cd nika-spec
nika run examples/showcase/t2-bookmark-triage.nika.yaml
open out/bookmarks.md
This workflow is newer than the currently shipped engine pack: nika examples run showcase/t2-bookmark-triage says unknown example until the next engine tag re-vendors the pack. Until then, run it from the spec checkout as above — the file is the same one the pack will embed, sha-pinned in the spec manifest.
Replace vars.bookmarks with your own export (most browsers dump bookmarks to HTML — one jq pass turns that into the URL list this file expects).