Skip to main content
T3 fan-out · strategy / product marketing — the flagship for_each. The number of pages is decided at RUNTIME by the competitor’s sitemap; max_parallel keeps the crawl polite, fail_fast: false keeps one dead page from killing the radar, retry: absorbs the flaky web.

The job

Monday 8am: what did they ship? Instead of 40 tabs, the sitemap is mapped, the 8 freshest pages are read concurrently, and one brief tells you what it signals. Every page that survives lands in the fan-in; every page that doesn’t is reported, not fatal.

The shape

The file

t3-competitor-radar.nika.yaml
nika: v1
workflow: competitor-radar
description: "Sitemap → parallel page reads → one competitive brief + ping"

model: mock/echo            # swap for anthropic/claude-sonnet-4-6

vars:
  competitor_sitemap: "https://competitor.example.com/sitemap.xml"

secrets:
  team_webhook:
    source: env
    key: TEAM_WEBHOOK_URL
    egress:                       # sanction the one send · the secret IS the URL
      - to: "nika:notify"
        host_from_self: true

tasks:
  - id: map
    invoke:
      tool: "nika:fetch"
      args:
        url: "${{ vars.competitor_sitemap }}"
        mode: sitemap
    output:
      recent: ".urls[:8]"             # cap the radar at the 8 freshest pages

  - id: pages
    depends_on: [map]
    for_each: ${{ tasks.map.recent }}
    max_parallel: 4                    # be polite · 4 fetches in flight max
    fail_fast: false                   # one dead page must not kill the radar
    on_error:
      recover: null                    # a dead page yields null at its index · the radar lives
    timeout: "30s"
    retry:
      max_attempts: 3
      backoff_strategy: exponential
      jitter: true
    invoke:
      tool: "nika:fetch"
      args:
        url: "${{ item }}"
        mode: article

  - id: digest
    depends_on: [pages]
    infer:
      prompt: |
        These are the pages a competitor published recently ·
        ${{ tasks.pages.output }}
        Write the Monday brief · what they shipped · what it signals ·
        what we should watch. One page, plain words.

  - id: save
    depends_on: [digest]
    invoke:
      tool: "nika:write"
      args:
        path: "./radar/competitor-brief.md"
        content: "${{ tasks.digest.output }}"
        create_dirs: true

  - id: ping
    depends_on: [save]
    invoke:
      tool: "nika:notify"
      args:
        channel: webhook
        target: "${{ secrets.team_webhook }}"
        message: "Competitor radar is ready · ./radar/competitor-brief.md"
        severity: info

outputs:
  brief: ${{ tasks.digest.output }}

How it works

1

The collection is computed, not hardcoded

for_each: ${{ tasks.map.recent }} — eight URLs this week, three next week. The DAG doesn’t change.
2

Resilience is per-iteration

retry: (exponential + jitter) and timeout: "30s" apply to EACH page fetch. fail_fast: false collects errors instead of aborting the batch.
3

The fan-in is just a reference

${{ tasks.pages.output }} is the ARRAY of per-page outputs, in input order. One prompt consumes the whole crawl.

Constructs you just used

ConstructWhereReference
for_each over a bindingpagesWorkflows
max_parallel + fail_fastpagesWorkflows
per-iteration retry: + timeout:pagesError model
${{ item }} loop-localpages.args.urlBindings

Make it yours

  • Track THREE competitors: lift the sitemap URL into a list and nest the pattern — or run the workflow per competitor and merge the briefs.
  • Filter the sitemap by date with a sharper jq binding before fanning out.
  • Schedule it for Monday 07:30 from your host’s cron — the brief is on your desk before standup.

Next · Localization factory

Chained fan-outs and the jq transpose zip — the whole docs tree, translated.