> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nika.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Bookmark triage

> T2 fan-out · personal / research: the bookmark pile triaged into one table — dead links survive the batch instead of killing it.

> **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

```mermaid theme={"system"}
flowchart LR
  pages["pages · ∥ for_each · nika:fetch"]:::invoke
  table["table · nika:jq"]:::invoke
  report["report · nika:write"]:::invoke
  pages --> table
  table --> report
  classDef invoke fill:#22d3ee22,stroke:#22d3ee,color:#22d3ee
```

## The file

```yaml t2-bookmark-triage.nika.yaml theme={"system"}
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

```bash theme={"system"}
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
```

<Note>
  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.
</Note>

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).
