AGPL-3.0-or-later · forever.

showcase · tier 2 · content

SEO content brief

Maps the competitor’s sitemap, reads their best page, then writes a typed brief that goes after the gaps.

Your writer starts from gaps and search intent, grounded in pages that actually rank. · conformance-gated in nika-spec ↗ · re-proven at every push

  • 4tasksthe plan falls out of the bindings
  • 4wavesparallel by construction
  • 2verbs exercisedinvoke · infer
  • T2the tiercontent

The whole file

4 tasks · 4 waves

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

seo-content-brief.nika.yamlsource
nika: v1workflow:  id: seo-content-brief  description: "Competitor sitemap → top page → gap analysis → typed brief"# A NON-thinking local model, deliberately: a thinking model can spend the# whole `max_tokens` budget in its think block and return before the JSON# (engine#428). Schema showcases pick a model that answers directly.model: ollama/llama3.2:3binputs:  topic:    type: string    # Slug-shaped on purpose: this value lands in a FILENAME below, and a    # caller-supplied string that reaches a path is the tainted-value case the    # checker polices (NIKA-AUTH-008). Keep it to one path segment — `*` in    # the write bound never crosses `/`, so `a/b` is refused, by design.    default: "rust-async-runtimes"    description: "The keyword/topic you want to rank for (one path segment)"const:  competitor_sitemap: "https://competitor.example.com/sitemap.xml"permits:  tools: ["nika:fetch", "nika:write"]  fs:    # One brief per topic, written DIRECTLY in out/briefs/. `*` matches a    # single path segment and never crosses `/`, so a topic carrying a slash    # is refused instead of steering the write into a subtree — which is    # exactly why this is `out/briefs/*.json` and not `out/briefs/**`.    write: ["out/briefs/*.json"]  net:    # Both fetches stay on the competitor's own host: the sitemap, then a URL    # that same sitemap listed. Net entries are exact host names, never globs —    # add YOUR competitor's host here when you point the const at it, or the    # fetch is refused at run and the recover path quietly takes over.    http: ["competitor.example.com"]tasks:  map:    invoke:      tool: "nika:fetch"      args:        url: "${{ const.competitor_sitemap }}"        mode: sitemap    output:      top: ".[:5] | map(.loc)"    on_error:      # The recovery stands in for the RAW response, so it has the shape a      # sitemap fetch returns — a root array of {loc, …} — and the `output:`      # jq above slices it exactly as it would slice the live one. Recover the      # BINDING's shape instead and the jq runs against the wrong thing and      # throws NIKA-VAR-004.      recover:        - { loc: "https://competitor.example.com/blog/async-runtimes-compared" }        - { loc: "https://competitor.example.com/blog/tokio-vs-smol" }        - { loc: "https://competitor.example.com/blog/choosing-an-executor" }  top_page:    with:      map_top: ${{ tasks.map.top[0] }}    invoke:      tool: "nika:fetch"      args:        url: "${{ with.map_top }}"        mode: article    on_error:      recover: |        Async runtimes compared — we benchmarked four executors on the same        workload. Tokio wins on ecosystem breadth; smol wins on binary size.        We did not cover structured concurrency, cancellation safety, or how        any of this behaves under a blocking call, which is where most teams        actually get hurt.  brief:    with:      map_top: ${{ tasks.map.top }}      top_page: ${{ tasks.top_page.output }}    infer:      max_tokens: 1200      prompt: |        Topic to rank for · ${{ inputs.topic }}        Competitor's top URLs · ${{ with.map_top }}        Their best page on it ·        ${{ with.top_page }}        Write a content brief that BEATS this page · find the gaps they        missed · angle for search intent.      schema:        type: object        additionalProperties: false        required: [title, angle, outline, keywords]        properties:          title: { type: string }          angle: { type: string }          outline: { type: array, items: { type: string } }          keywords: { type: array, items: { type: string } }  save:    with:      brief: ${{ tasks.brief.output }}    invoke:      tool: "nika:write"      args:        # `.json` content is ONE interpolated value — the engine serializes it.        path: "out/briefs/${{ inputs.topic }}.json"        content: "${{ with.brief }}"        create_dirs: trueoutputs:  brief:    value: ${{ tasks.brief.output }}    description: "The typed content brief"

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

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