Skip to main content
Ops work is where Β« the agent ran a command Β» stops being acceptable and Β« the file says exactly which commands, and the runtime enforces it Β» starts to matter. This guide builds a Docker health report the audited way: two pinned docker reads, one local model, one kept artifact.

The whole workflow

Checked clean against nika 0.97.0 (nika check exit 0 Β· permits declared Β· 0 hints):
# EXEC LEDGER Β·
# | task | command          | why no native path               | unlock that removes it |
# | ps   | docker ps        | product CLI Β· no MCP surface yet | a docker MCP server    |
# | df   | docker system df | same                             | same                   |
nika: v1
workflow: docker-health-report
description: read the Docker daemon's state, have a local model explain it, keep the report

model: ollama/qwen3.5:4b

permits:
  exec:
    - "docker"
  tools:
    - "nika:write"
  fs:
    write:
      - "./docker-health.md"

tasks:
  - id: ps
    exec:
      command: ["docker", "ps", "--all", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}"]

  - id: df
    exec:
      command: ["docker", "system", "df"]

  - id: diagnose
    depends_on: [ps, df]
    infer:
      prompt: |
        You are reading a Docker host's state. Containers (nameΒ·statusΒ·image):
        ${{ tasks.ps.output }}

        Disk usage:
        ${{ tasks.df.output }}

        Write a short health report: what is running, what exited, what
        looks unhealthy (restart loops Β· old exits), and whether disk
        usage needs attention. Plain prose, no preamble.
      max_tokens: 600

  - id: keep
    depends_on: [diagnose]
    invoke:
      tool: "nika:write"
      args:
        path: "./docker-health.md"
        content: "${{ tasks.diagnose.output }}"

outputs:
  report: ${{ tasks.keep.output }}

Why the exec tasks are arrays

Write command: "docker ps | grep Up" and nika check refuses to bless the boundary:
βœ– PERMITS  [exec] task `ps` Β· a shell-string command cannot be verified
           against a `permits.exec` program allowlist (a pipeline can
           launch any program) β€” use the array form
That refusal is the feature. A shell string can smuggle any program through a pipe; the argv array names exactly one program with exactly those arguments, so permits.exec: ["docker"] is a boundary the checker can actually prove. Transforms that would have lived in | grep | awk belong in output: bindings and nika:jq instead.

What the audit says before a token is spent

βœ” PLAN     3 wave(s) Β· 4 task(s) Β· max parallelism 2
     wave 1 ps (exec Β· docker) Β· df (exec Β· docker)
     wave 2 diagnose (infer Β· ollama/qwen3.5:4b)
     wave 3 keep (invoke Β· nika:write)
⚠ COST     $0.0000 – $0.0000 FLOOR (unbounded tasks present)
  diagnose  ollama/qwen3.5:4b  UNBOUNDED β€” no catalog price (local/unknown model)
βœ” PERMITS  body fits the declared boundary
βœ” audited Β· 4 task(s) Β· 3 wave(s) Β· permits declared Β· est β‰₯$0.0000 Β· 0 hints
Two things worth noticing:
  • The two docker reads run in parallel (wave 1) β€” the scheduler proved it from depends_on, nobody ordered it.
  • The cost line practices the honesty rule: a local model is unpriced compute, not Β« free Β» β€” the report says FLOOR and names why, it never rounds unknown to $0.

Run it

nika run docker-health-report.nika.yaml
Captured verbatim (Apple-silicon laptop Β· Docker Desktop Β· a small local thinking model β€” the two-minute infer is the model, not the engine):
πŸ¦‹ nika Β· docker-health-report Β· 4 tasks
   permits βœ“ declared boundary Β· default-deny

βœ”  ps        exec Β· docker              108ms βˆ₯
βœ”  df        exec Β· docker              561ms βˆ₯
βœ”  diagnose  infer Β· ollama/qwen3.5:4b  2m16s
βœ”  keep      invoke Β· nika:write          2ms
── 4/4 done Β· $0.00 Β· elapsed 137.3s ───────────────────────────
  trace: .nika/traces/2026-07-08T12-43-02Z-d3f3.ndjson Β· 14 events Β· chain 5667b30f53baf619227324e70871de71
docker-health.md now holds the model’s prose, and the run left a hash-chained journal you can prove later:
nika trace verify .nika/traces/<run>.ndjson

When the daemon is down

The failure is part of the design. With Docker stopped, the same run fails honestly and early β€” the model is never called, nothing is spent, and the card hands over:
βœ–  ps        exec Β· docker              167ms βˆ₯
⊘  diagnose  blocked · upstream failed
⊘  keep      blocked · upstream failed
── 4/4 done Β· $0.00 Β· elapsed 0.2s ─────────────────────────────

βœ– NIKA-EXEC-001 Β· command exited with status 1: failed to connect to the
  docker API at unix:///…/docker.sock; check if the path is correct and
  if the daemon is running
  fix: nika explain NIKA-EXEC-001

Take it further

  • Gate an action on the report β€” add an agent: task allowed only nika:done, or a when: gate on a structured diagnose output, and the workflow can act on unhealthy containers, still inside the declared boundary. See patterns.
  • Schedule it β€” the file is the artifact: cron or CI runs nika run and every execution leaves a verifiable trace.
  • Structured instead of prose β€” give diagnose a schema: and the report becomes typed data downstream tasks can branch on. See testing for pinning it with a golden.
The two exec tasks carry the repo’s exec ledger (the header comment): every surviving exec: names why no builtin or MCP tool covers it yet, and what would remove it. When a Docker MCP server enters your stack, invoke: replaces both reads and the ledger empties β€” that is the native-first law working as intended.