AGPL-3.0-or-later · forever.

the skeleton register

agent-loop

Your intent sounds like: research / review / open-ended One of 10 skeletons the pack ships, green as-is under nika check. Fill the # SLOT: lines, repair from the fix lines, re-check. Machines read the catalog; the binary lists them with nika new '?'.

  • 8SLOT pointsthe only lines you edit
  • 4patterns lockedarrive correct, stay correct
  • 3tools grantedthe minimum for the job
  • 1c594179sha-pinnedre-hashed on every test run

the plan

derived from the file, never drawn by hand
planinfer
executeagent
confirminvoke

the file, whole

conformance-gated upstream on every spec push
agent-loop.nika.yaml
# SPDX-License-Identifier: Apache-2.0# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json## TEMPLATE · agent-loop · plan fast → execute with a leashed agent → validate the typed result.##   [plan]────▶[execute]─────▶[confirm]#   infer       agent·leashed  nika:assert## The open-ended job — research, review, triage — where the steps cannot be# written in advance. A cheap `infer` drafts the plan, a LEASHED agent works# it (bounded turns, bounded spend, a default-deny tool grant), and an assert# refuses to pass an empty result downstream.## Demonstrates ·#   - `agent:` under the full leash · `max_turns` + `max_tokens_total` + an#     explicit `tools:` list · nothing outside it is reachable#   - `schema:` on the agent · the engine owns the final shape and RE-ASKS#     until it conforms — you never hand-instruct it (see `execute`)#   - `nika:assert` as a trust boundary · an empty run must not read as a#     successful one## Needs · nothing. Scaffolds and runs green in an empty directory.## Run · nika run <file> --model mock/echo                    # offline rehearsal#     · nika run <file> --var goal="triage the open bugs"    # a real seatnika: v1workflow:  id: agent-loop-template       # SLOT: kebab-case workflow id  # SLOT: one honest sentence — `nika new` matches intents against these words  description: "open-ended research / review / triage · plan → budgeted agent → typed result"# SLOT: a TOOL-CALLING model · local by default. This seat is measured, not# assumed: `ollama/qwen2.5:14b` completes the planning call in ~65s under the# ceiling below. A reasoning seat (`ollama/qwen3.5:4b`) spends its whole# budget thinking and never emits JSON here — the guaranteed offline path is# `--model mock/echo`, which needs no seat at all.model: ollama/qwen2.5:14binputs:  goal:    type: string    required: true    # A `required:` input still carries a `default:` here so the skeleton    # RUNS the moment it is scaffolded. Replace the default with your job;    # `--var goal=…` overrides it at any time.    default: "list the risks of running an agent without a turn budget"   # SLOT    description: "What the agent must accomplish"   # SLOTpermits:                            # the blast radius · default-deny once present  # Exactly what the body invokes: `nika:assert` (the confirm task) plus the  # two the agent may call. No `fs:` — see the note on `tools:` below.  tools: ["nika:assert", "nika:done", "nika:jq"]tasks:  plan:    infer:      prompt: "Break '${{ inputs.goal }}' into at most 4 concrete steps."   # SLOT      # SLOT: the spend ceiling for the planning call. Size it for the SEAT,      # not for the answer: a reasoning model spends tokens thinking before      # it emits the first brace, and a ceiling that cuts it off mid-thought      # fails NIKA-INFER-002 ("no JSON value found · the reply was cut off at      # the token limit"). Measured — 400 starves qwen3.5:4b on this prompt.      max_tokens: 2000      schema:        type: object        additionalProperties: false        required: [steps]        properties:          steps: { type: array, items: { type: string } }  execute:    with:      steps: ${{ tasks.plan.output.steps }}    agent:      # Say what the JOB is. Do NOT describe the output shape.      #      # The engine binds `schema:` to the FINAL answer: a free-text answer      # that does not conform is RE-ASKED with the schema wired, bounded by a      # retry budget. Measured on ollama/qwen3.5:4b — this task, with zero      # shape instruction, returns a clean typed object.      #      # Hand-instructing "reply with the object, then call nika:done" is      # actively harmful: a `nika:done` carrying `result:` is validated      # DIRECTLY and is NEVER re-asked (nika-verb-agent/src/lib.rs · "a miss      # is a verdict, never a re-ask"), so a model that hands its JSON back      # as a *string* dies NIKA-INFER-002 with the budget already spent.      # Measured twice on that exact instruction. Let the engine own shape.      system: "You are a careful analyst. Work the plan step by step and report what you actually found."   # SLOT      prompt: "Plan · ${{ with.steps }}"      tools:                        # SLOT: the MINIMUM grant for the job        # Pure compute — this pair needs no filesystem, so the skeleton runs        # anywhere. Granting the agent a tool here is only HALF a grant: every        # call still crosses the workflow boundary above. Adding `nika:read`        # to this list WITHOUT adding `permits.fs.read` fails at run with        # `NIKA-SEC-004 · agent tool "nika:read" refused by the security        # boundary` — measured, mid-loop, after the turns are paid for.        - "nika:jq"        - "nika:done"               # the early-exit sentinel · loop-owned      max_turns: 15                 # SLOT: the loop bound      max_tokens_total: 80000       # SLOT: the spend bound      schema:                       # SLOT: the typed final-message contract        type: object        additionalProperties: false        required: [findings]        properties:          findings: { type: array, items: { type: string } }  confirm:    with:      has_findings: ${{ size(tasks.execute.output.findings) > 0 }}   # the check crosses as ONE boundary expression    invoke:      tool: "nika:assert"      args:        condition: "${{ with.has_findings }}"        message: "Agent returned no findings, do not trust an empty run"   # SLOToutputs:  findings:    value: ${{ tasks.execute.output.findings }}    description: "The agent's typed findings"   # SLOT

sha256 1c594179c46ff834. The copy above re-hashes to its pin on every test run (a copy is re-provable, never trusted). Source: agent-loop.nika.yaml in the spec pack · open it in the playground →

the patterns it locks

  • plan-then-execute
  • default-deny tools
  • budgets
  • engine-owned typed result

Scaffold it locally: nika new agent-loop my-flow.nika.yaml. Try the shape in the playground, or walk the showcase for the same patterns on real work. Read the spec →