AGPL-3.0-or-later · forever.

showcase · tier 4 · research

Deep research brief

A fast model writes the plan, an agent works the web inside hard budgets, a thinking model writes the brief.

“Get me up to speed by Thursday” becomes a pipeline you can audit end to end. · conformance-gated in nika-spec ↗ · re-proven at every push

  • 5tasksthe plan falls out of the bindings
  • 4wavesparallel by construction
  • 3verbs exercisedinvoke · infer · agent
  • T4the tierresearch

The whole file

5 tasks · 4 waves

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

deep-research-brief.nika.yamlsource
nika: v1workflow:  id: deep-research-brief  description: "plan → budgeted research agent → thinking synthesis → brief on disk"model: ollama/qwen3.5:4b   # local · zero key · `--model mock/echo` rehearses the whole fileinputs:  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("^-|-
quot;; "")'
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: 150000 # and the second leash, in tokens 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 ». thinking: # A reasoning budget, honoured by seats that support extended thinking # and ignored by the ones that do not — declaring it never fails a run. enabled: true budget_tokens: 8000 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: trueoutputs: 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"

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

5 declared edges · 4 waves · the plan falls out of the bindings