AGPL-3.0-or-later · forever.

showcase · tier 3 · research

Competitor radar

Maps their sitemap, reads the freshest pages in parallel with retry, then digests one brief.

Monday 8am, everything they shipped last week is on your desk, with what it signals. · conformance-gated in nika-spec ↗ · re-proven at every push

  • 10tasksthe plan falls out of the bindings
  • 8wavesparallel by construction
  • 2verbs exercisedinvoke · infer
  • T3the tierresearch

The whole file

10 tasks · 8 waves

The plan falls out of the bindings, nothing is scheduled by hand.

competitor-radar.nika.yamlsource
nika: v1workflow:  id: competitor-radar  description: "sitemap → what changed this window → parallel page reads → one brief"model: ollama/qwen3.5:4b   # local · zero key · swap for anthropic/claude-sonnet-4-6 for a sharper readrun:  clock: system            # `pages` declares a `timeout:` · name the clock it ridesinputs:  sitemap_url:    type: string    default: "https://nika.sh/sitemap.xml"    description: "Whose sitemap to read · must agree with the host in permits.net.http"  window_days:    type: integer    default: 7    description: "How far back « recently » reaches"  max_pages:    type: integer    default: 6    description: "Politeness cap · the fan-out never exceeds this many fetches"permits:  tools: ["nika:date", "nika:emit", "nika:fetch", "nika:jq", "nika:write"]  net:    # ONE host covers BOTH fetches, and that is a claim worth being explicit    # about: `map` reads the sitemap, then `pages` fetches whatever `.loc`    # values came back — runtime URLs this file never sees. A sitemap is    # single-host by protocol, so the site's own host is the honest wall: if    # their sitemap ever points somewhere else, that fetch is refused instead    # of followed. Change `inputs.sitemap_url` and this line changes with it.    http: ["nika.sh"]  fs:    # ONE file, named exactly, because the path IS a literal — there is no    # uuid or date in it, so there is nothing for a wildcard to stand in for.    # A bound is only as wide as the uncertainty it has to cover.    #    # `create_dirs: true` below makes the missing `./radar/` without needing    # a grant of its own: measured, with only this line, the directory is    # created and the brief lands. (Builtins that take an `output_dir:`    # instead — `nika:image_generate` and family — are the opposite case:    # `check` judges the directory ARGUMENT while the run gates each final    # file path, so those need the directory AND its children. The bound    # follows what the builtin names.)    write: ["./radar/competitor-brief.md"]tasks:  # ── how far back does « recently » reach ───────────────────────────  # Three date ops rather than a hardcoded string, because a radar that ships  # with a date baked in is wrong the day after you write it. `format` to  # %Y-%m-%d puts the cutoff in the same shape sitemaps use for `lastmod`,  # which is what makes the string comparison below sound.  now:    invoke:      tool: "nika:date"      args: { op: now }  cutoff:    with:      now: ${{ tasks.now.output }}    invoke:      tool: "nika:date"      args:        op: subtract        base: "${{ with.now }}"        duration: "${{ inputs.window_days }}d"  cutoff_day:    with:      cutoff: ${{ tasks.cutoff.output }}    invoke:      tool: "nika:date"      args:        op: format        input: "${{ with.cutoff }}"        format: "%Y-%m-%d"  # ── what they publish, and when it last moved ──────────────────────  map:    invoke:      tool: "nika:fetch"      args:        url: "${{ inputs.sitemap_url }}"        mode: sitemap                  # → [{loc, lastmod, changefreq, priority}, …]  # `lastmod` is optional in the sitemap protocol, so `// ""` sends an entry  # without one outside the window instead of crashing the filter. Newest  # first, then the politeness cap — the fan-out width is decided here and  # nowhere else.  recent:    with:      entries: ${{ tasks.map.output }}      cutoff: ${{ tasks.cutoff_day.output }}    invoke:      tool: "nika:jq"      args:        input: { entries: "${{ with.entries }}", cutoff: "${{ with.cutoff }}" }        expression: >-          .cutoff as $c          | .entries          | map(select((.lastmod // "") >= $c))          | sort_by(.lastmod) | reverse          | .[:${{ inputs.max_pages }}]          | map(.loc)  # ── read them all at once ──────────────────────────────────────────  pages:    with:      urls: ${{ tasks.recent.output }}    for_each: ${{ with.urls }}    max_parallel: 4                    # be a good guest · four in flight, no more    fail_fast: false                   # one dead page must not kill the radar    on_error:      recover: null                    # that page yields null at its index · the batch lives    timeout: "30s"                     # somebody else's server, somebody else's bad day    retry:      max_attempts: 3      backoff_strategy: exponential      jitter: true                     # a swarm that retries in lockstep is a small DDoS    invoke:      tool: "nika:fetch"      args:        url: "${{ item }}"        mode: article                  # readability extraction · the prose, not the chrome  # ── the fan-in · a for_each output is an array in ITEM ORDER ───────  # which is what lets `transpose` put each page's text back beside its own  # URL. The `select` is where the pages that never answered leave.  readable:    with:      urls: ${{ tasks.recent.output }}      pages: ${{ tasks.pages.output }}    invoke:      tool: "nika:jq"      args:        input: ["${{ with.urls }}", "${{ with.pages }}"]        expression: >-          transpose          | map(select(.[1] != null))          | map({ url: .[0], text: .[1] })  digest:    with:      readable: ${{ tasks.readable.output }}      cutoff: ${{ tasks.cutoff_day.output }}    when: ${{ size(with.readable) > 0 }}   # a quiet week costs nothing    infer:      max_tokens: 1500                     # one page · a ceiling, not a hope      prompt: |        These are the pages a competitor published or changed since        ${{ with.cutoff }} ·        ${{ with.readable }}        Write the Monday brief · what they shipped · what it signals · what        we should watch. One page, plain words, no filler.  save:    with:      digest: ${{ tasks.digest.output }}    when: ${{ with.digest != null }}   # a skipped task reads null downstream · gate on the VALUE    invoke:      tool: "nika:write"      args:        path: "./radar/competitor-brief.md"        content: "${{ with.digest }}"        create_dirs: true  # The « it is ready » signal, as a LOCAL journal event. No webhook, no  # secret, no host: the brief is on disk and the event says so. A radar that  # needed an outbound credential to tell you it had finished would be a  # strictly larger blast radius for no extra information.  record:    with:      urls: ${{ tasks.recent.output }}      readable: ${{ tasks.readable.output }}      cutoff: ${{ tasks.cutoff_day.output }}    invoke:      tool: "nika:emit"      args:        event_type: "competitor.radar.scan"        payload:          since: ${{ with.cutoff }}          selected: ${{ with.urls }}          read_ok: ${{ with.readable }}outputs:  since:    value: ${{ tasks.cutoff_day.output }}    description: "The computed window start · what « recently » meant on this run"  pages:    value: ${{ tasks.recent.output }}    description: "The URLs the sitemap said had changed inside that window"  brief:    value: ${{ tasks.digest.output }}    description: "The Monday brief · null when the week was quiet"

nika inspect · engine 0.106.1 · vendored graph, never re-derived

13 declared edges · 8 waves · the plan falls out of the bindings