Skip to main content
T2 chain · finance / freelance — the human-in-the-loop example. nika:prompt blocks the workflow until someone answers; the drafting is automated, the sending decision never is.

The job

Friday: open the ledger, find who’s late, write polite-but-firm reminders, don’t send the awkward one to the client who paid this morning. This file filters the CSV deterministically (jq, not an LLM guessing at numbers), drafts every reminder, then stops and asks you.

The shape

The file

t2-invoice-chaser.nika.yaml
nika: v1
workflow: invoice-chaser
description: "Ledger CSV → overdue filter → drafted reminders → human gate → drafts file"

model: mock/echo            # swap for groq/llama-3.3-70b — drafting is a fast-model job

vars:
  ledger_csv: "./finance/invoices.csv"

tasks:
  - id: ledger
    invoke:
      tool: "nika:read"
      args: { path: "${{ vars.ledger_csv }}" }

  - id: rows
    depends_on: [ledger]
    invoke:
      tool: "nika:convert"
      args:
        input: "${{ tasks.ledger.output }}"
        from: csv
        to: json
        has_header: true

  - id: overdue
    depends_on: [rows]
    invoke:
      tool: "nika:jq"
      args:
        input: "${{ tasks.rows.output }}"
        expression: 'map(select(.status == "overdue"))'

  - id: drafts
    depends_on: [overdue]
    when: ${{ size(tasks.overdue.output) > 0 }}
    infer:
      prompt: |
        Draft one short, polite payment reminder per overdue invoice ·
        ${{ tasks.overdue.output }}
        Markdown · one section per client · firm but warm.

  - id: approve
    depends_on: [drafts]
    when: ${{ tasks.drafts.output != null }}   # nothing drafted · nothing to approve
    invoke:
      tool: "nika:prompt"
      args:
        message: "Save the reminder drafts for sending?"
        default: false

  - id: save
    depends_on: [approve, drafts]
    when: ${{ tasks.drafts.output != null && tasks.approve.output == true }}   # zero overdue → drafts skipped (null) · don't write nothing
    invoke:
      tool: "nika:write"
      args:
        path: "./finance/reminders-to-send.md"
        content: "${{ tasks.drafts.output }}"

outputs:
  overdue:
    value: ${{ tasks.overdue.output }}
    type: array
    description: "The overdue rows the reminders were drafted for"

How it works

1

Data work is jq work

CSV → JSON via nika:convert, then map(select(.status == "overdue")) in nika:jq. No model touches the numbers — deterministic, free, auditable.
2

size() gates the whole tail

when: ${{ size(tasks.overdue.output) > 0 }} — the canonical empty-check. No overdue invoices → the rest of the file skips.
3

The approval gate

nika:prompt blocks until answered (CI can use default:). The save runs only when: ${{ tasks.approve.output == true }}.

Constructs you just used

ConstructWhereReference
nika:convert from/torowsBuiltins
nika:jq filteringoverdueBuiltins
CEL size()drafts.whenWorkflows
nika:prompt human gateapproveBuiltins

Make it yours

  • Per-client emails instead of one file: for_each over the overdue rows (fan-out tier).
  • Escalate 60-days-late differently: a second jq filter + a sterner prompt.
  • Wire the approved drafts to your mail API with nika:fetch method: POST.

Next · Support triage

Classify a whole queue in one schema-typed call, then let jq slice the urgent ones out.