nika: deep-research-brief
model: ollama/qwen3.5:4b # local · zero key · `--model mock/echo` rehearses the whole file
inputs:
topic:
type: string
default: "local-first AI runtimes"
description: "What to research · free text (--var topic=…), and it never touches a path directly"
permits:
tools: ["nika:done", "nika:fetch", "nika:jq", "nika:write"]
net:
# There is no `*` here and there never will be: `net.http` entries are exact
# host names. An agent told to « research the web » therefore cannot — the
# corpus it may reach is a decision taken HERE, before the loop starts,
# rather than discovered afterwards in a trace. Add the sources you actually
# trust; the agent's fetch is judged against this list at RUN, and `check`
# cannot see it coming because the agent chooses its own URLs.
http:
- "arxiv.org"
- "export.arxiv.org"
- "en.wikipedia.org"
- "hn.algolia.com"
fs:
# One brief per topic, written DIRECTLY in ./research/. `*` matches a single
# segment and never crosses `/`, so even a slug that somehow kept a slash is
# refused rather than steered into a subtree — `./research/**` would let it
# through. The slug task below makes that doubly hard; the wall makes it
# impossible, and only one of the two is a guarantee.
write: ["./research/*.md"]
tasks:
# The topic is free text — « Local-First AI Runtimes: a survey/2026 » — and it
# ends up in a filename. Deriving the slug in jq keeps that arithmetic out of
# the model AND out of the path: the expression is total, so there is no input
# for which it returns something containing a separator.
slug:
invoke:
tool: "nika:jq"
args:
input: "${{ inputs.topic }}"
expression: 'ascii_downcase | gsub("[^a-z0-9]+"; "-") | gsub("^-|-$"; "")'
plan:
infer:
max_tokens: 400 # planning is short · cap it and the cost report is a ceiling
prompt: "Break '${{ inputs.topic }}' into 4 sharp research queries · no overlap."
schema:
# `additionalProperties: false` on every object node — without it the
# shape is a suggestion and each provider answers with a slightly
# different object. `plan.output.queries` is read below, so it has to be
# a promise, not a hope.
type: object
additionalProperties: false
required: [queries]
properties:
queries:
type: array
items: { type: string }
investigate:
with:
queries: ${{ tasks.plan.output.queries }}
agent:
system: |
You are a rigorous researcher. Work the queries one by one · fetch
sources · keep verbatim quotes · note what each source actually
supports, and what it does not. If a fetch is refused, record that the
source was out of bounds rather than substituting another.
Call nika:done when the plan is exhausted.
prompt: "Research plan · ${{ with.queries }}"
tools:
# Every id here must also appear in `permits.tools` above — `check`
# refuses the mismatch statically (NIKA-SEC-004 · agent tool … is
# outside permits.tools). Note what is NOT granted: `nika:write`. The
# agent's product is the typed object it returns, so it never needs a
# file of its own, and the workflow keeps the only pen in the room.
- "nika:fetch" # the corpus named in permits.net.http
- "nika:done" # the loop-completion sentinel
max_turns: 25 # the leash · a loop that cannot end is a bill
max_tokens_total: 120000 # cumulative input + output across requests, not a context window
schema:
type: object
additionalProperties: false
required: [findings, sources]
properties:
findings:
type: array
items: { type: string }
sources:
type: array
items: { type: string }
brief:
with:
findings: ${{ tasks.investigate.output.findings }}
sources: ${{ tasks.investigate.output.sources }}
infer:
max_tokens: 2000
prompt: |
Findings · ${{ with.findings }}
Sources · ${{ with.sources }}
Write the executive brief · what's true · what's contested · what it
means · what to do. Two pages max. Anything the sources do not support
goes in « contested », never in « true ».
# No `thinking:` block on purpose: the seat law REFUSES a thinking
# declaration on a model the catalog knows cannot reason (a dead
# declaration — the wire drops the budget), and the local seat this
# file runs on is exactly that. Seat `brief` on a reasoning-capable
# model via its own `model:` — THAT is what licenses
# `thinking: { enabled: true, budget_tokens: … }`.
save:
with:
slug: ${{ tasks.slug.output }}
brief: ${{ tasks.brief.output }}
invoke:
tool: "nika:write"
args:
path: "./research/${{ with.slug }}.md"
content: "${{ with.brief }}"
create_dirs: true
outputs:
brief: ${{ tasks.brief.output }}
sources:
value: ${{ tasks.investigate.output.sources }}
description: "Every source the agent actually used"
slug:
value: ${{ tasks.slug.output }}
description: "The filename stem the brief landed under · ./research/<slug>.md"