nika: v1
workflow:
id: competitor-radar
description: "competitor watch Β· their sitemap says what shipped or changed this week β parallel page reads β one Monday brief"
model: ollama/qwen3.5:4b # local Β· zero key Β· swap for anthropic/claude-sonnet-4-6 for a sharper read
run:
clock: system # `pages` declares a `timeout:` Β· name the clock it rides
inputs:
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 && size(with.digest) > 0 }} # skipped (null) or an EMPTY answer Β· gate on the VALUE, both ways
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"