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("^-|-$"; "")'  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.107.0 · vendored graph, never re-derived

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