Skip to main content

Close the Monthly Books

A month-end close spans four layers:

  • Reconciliation pass — an orchestration: accounts reconcile in parallel, variances converge, a branch decides.
  • Close period — a workflow: lives for days and moves backward when the controller sends it back.
  • Cadence — a trigger on the first of the month.
  • Sign-off — an approval recorded for audit.

Every routing decision is arithmetic evaluated by JSON Logic; the agent only writes the controller a readable note.

Read Conditional Branching and Orchestration Control Flow first; this tutorial composes those pieces.

The figures are fixtures, not accounting guidance. A real close would pull balances from your ledger through tool nodes instead of run input.

Prerequisites​

  • SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
  • New to SOAT? Read Key Concepts for projects, agents, orchestrations, and tasks.
  • CLI installed and configured, or SDK set up. See CLI or SDK.
  • Ollama reachable by the server, for the one agent node.
  • For production hardening (secrets, env vars), see Configuration.
  • Server is at http://localhost:5047.
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin​

Admin is the built-in superuser role. See Users for authentication details.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project​

Every resource below lives inside one project.

PROJECT_ID=$(soat create-project --name "Monthly Close" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"

Step 3 — Create the AI provider and the variance-memo agent​

One AI provider and one agent. The agent turns a number into a sentence; it never decides whether the books balance and has no output_schema because nothing downstream parses its text.

This tutorial uses a local Ollama provider. To connect xAI, OpenAI, Anthropic, or Amazon Bedrock instead, see Connect Third-Party LLMs.

AI_PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Local Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')
echo "AI_PROVIDER_ID: $AI_PROVIDER_ID"

MEMO_AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$AI_PROVIDER_ID" \
--name "Variance Memo Agent" \
--instructions 'You are an accounting assistant. You receive a period and an unreconciled variance. Reply with two plain sentences telling the controller what to investigate first. No markdown, no headings, no lists.' \
--max-steps 1 | jq -r '.id')
echo "MEMO_AGENT_ID: $MEMO_AGENT_ID"

Step 4 — Validate and create the reconciliation graph​

Seven nodes. The three reconciliations have no incoming edges, so they are start nodes and run in parallel. Their edges share an activation_group with activation_condition: "all", making total_variance a join barrier that waits for all three.

NodeTypePurpose
bank_recontransformAbsolute difference between ledger cash and the bank statement
ar_recontransformAbsolute difference between the AR control account and its subledger
ap_recontransformAbsolute difference between the AP control account and its subledger
total_variancetransformSums the three variances once all have landed
gate_checkconditionEmits "clean" or "exception" by comparing the total against tolerance
clean_summarytransformThe clean branch — records that the period tied out
draft_memoagentThe exception branch — writes the controller a note

JSON Logic has no absolute-value operator, so each reconciliation uses if to pick the positive subtraction order. Run input and run state are different namespaces: read input as {"var": "input.tolerance"} and a state key written upstream as a bare {"var": "total_variance"}.

validate-orchestration checks the graph statically without persisting anything. See Orchestrations for the node reference.

CLOSE_NODES='[
{ "id": "bank_recon", "type": "transform",
"expression": { "if": [
{ "<": [ { "-": [ { "var": "input.ledger.cash" }, { "var": "input.statements.bank" } ] }, 0 ] },
{ "-": [ { "var": "input.statements.bank" }, { "var": "input.ledger.cash" } ] },
{ "-": [ { "var": "input.ledger.cash" }, { "var": "input.statements.bank" } ] } ] },
"state_mapping": { "state.bank_variance": { "var": "output.result" } } },
{ "id": "ar_recon", "type": "transform",
"expression": { "if": [
{ "<": [ { "-": [ { "var": "input.ledger.ar" }, { "var": "input.statements.ar_subledger" } ] }, 0 ] },
{ "-": [ { "var": "input.statements.ar_subledger" }, { "var": "input.ledger.ar" } ] },
{ "-": [ { "var": "input.ledger.ar" }, { "var": "input.statements.ar_subledger" } ] } ] },
"state_mapping": { "state.ar_variance": { "var": "output.result" } } },
{ "id": "ap_recon", "type": "transform",
"expression": { "if": [
{ "<": [ { "-": [ { "var": "input.ledger.ap" }, { "var": "input.statements.ap_subledger" } ] }, 0 ] },
{ "-": [ { "var": "input.statements.ap_subledger" }, { "var": "input.ledger.ap" } ] },
{ "-": [ { "var": "input.ledger.ap" }, { "var": "input.statements.ap_subledger" } ] } ] },
"state_mapping": { "state.ap_variance": { "var": "output.result" } } },
{ "id": "total_variance", "type": "transform",
"expression": { "+": [ { "var": "bank_variance" }, { "var": "ar_variance" }, { "var": "ap_variance" } ] },
"state_mapping": { "state.total_variance": { "var": "output.result" } } },
{ "id": "gate_check", "type": "condition",
"expression": { "if": [ { "<=": [ { "var": "total_variance" }, { "var": "input.tolerance" } ] }, "clean", "exception" ] } },
{ "id": "clean_summary", "type": "transform",
"expression": { "cat": [ "Period ", { "var": "input.period" }, " tied out within tolerance." ] },
"state_mapping": { "state.close_note": { "var": "output.result" } } },
{ "id": "draft_memo", "type": "agent", "agent_id": "'"$MEMO_AGENT_ID"'",
"input_mapping": { "prompt": { "cat": [ "Period ", { "var": "input.period" }, " has an unreconciled variance of ", { "var": "total_variance" }, " USD across bank, AR and AP." ] } },
"state_mapping": { "state.memo": { "var": "output.content" } } }
]'

CLOSE_EDGES='[
{ "from": "bank_recon", "to": "total_variance", "activation_group": "recon", "activation_condition": "all" },
{ "from": "ar_recon", "to": "total_variance", "activation_group": "recon", "activation_condition": "all" },
{ "from": "ap_recon", "to": "total_variance", "activation_group": "recon", "activation_condition": "all" },
{ "from": "total_variance", "to": "gate_check" },
{ "from": "gate_check", "to": "clean_summary", "condition": "clean" },
{ "from": "gate_check", "to": "draft_memo", "condition": "exception" }
]'

soat validate-orchestration --nodes "$CLOSE_NODES" --edges "$CLOSE_EDGES" \
| jq '{valid, problems, warnings}'

CLOSE_ORCH_ID=$(soat create-orchestration \
--project-id "$PROJECT_ID" \
--name "Month-End Reconciliation" \
--description "Reconciles bank, AR and AP in parallel and routes on total variance" \
--nodes "$CLOSE_NODES" \
--edges "$CLOSE_EDGES" | jq -r '.id')
echo "CLOSE_ORCH_ID: $CLOSE_ORCH_ID"

Expected validation output:

{
"valid": true,
"problems": [],
"warnings": []
}

Step 5 — Run a clean close​

Start a run with books that balance. All three reconciliations return 0, 0 <= 1 routes down the clean edge, and draft_memo is recorded as skipped: no model was called.

A run starts asynchronously: start-orchestration-run enqueues it and returns status: "queued" with an empty state; a worker drives it. Read results from get-orchestration-run once the run reaches a terminal status. Below, # → retry N re-runs a jq -e assertion until the run settles; jq -e supplies the exit code the runner needs.

CLEAN_RUN_ID=$(soat start-orchestration-run \
--orchestration-id "$CLOSE_ORCH_ID" \
--input '{
"period": "2026-07",
"ledger": { "cash": 128450.25, "ar": 64200.00, "ap": 31775.50 },
"statements": { "bank": 128450.25, "ar_subledger": 64200.00, "ap_subledger": 31775.50 },
"tolerance": 1
}' | jq -r '.id')
echo "CLEAN_RUN_ID: $CLEAN_RUN_ID"

# → retry 60
soat get-orchestration-run --orchestration-run-id "$CLEAN_RUN_ID" | jq -e '.status == "completed"'

soat get-orchestration-run --orchestration-run-id "$CLEAN_RUN_ID" \
| jq '{status, total_variance: .state.total_variance, close_note: .state.close_note}'

soat list-orchestration-run-node-executions --orchestration-run-id "$CLEAN_RUN_ID" \
| jq '[.data[] | {node_id, status}]'

Expected output once the run settles:

{
"status": "completed",
"total_variance": 0,
"close_note": "Period 2026-07 tied out within tolerance."
}

draft_memo appears with "status": "skipped".


Step 6 — Run a close that finds a variance​

Same graph, one changed figure: the bank statement is 127200.25 against ledger cash of 128450.25. bank_recon returns 1250, the total exceeds the tolerance of 1, gate_check routes down exception, and the agent writes the memo. clean_summary is the skipped node.

EXCEPTION_RUN_ID=$(soat start-orchestration-run \
--orchestration-id "$CLOSE_ORCH_ID" \
--input '{
"period": "2026-08",
"ledger": { "cash": 128450.25, "ar": 64200.00, "ap": 31775.50 },
"statements": { "bank": 127200.25, "ar_subledger": 64200.00, "ap_subledger": 31775.50 },
"tolerance": 1
}' | jq -r '.id')
echo "EXCEPTION_RUN_ID: $EXCEPTION_RUN_ID"

# This path calls the model, so it settles more slowly than the clean run.
# → retry 120
soat get-orchestration-run --orchestration-run-id "$EXCEPTION_RUN_ID" | jq -e '.status == "completed"'

soat get-orchestration-run --orchestration-run-id "$EXCEPTION_RUN_ID" \
| jq '{status, bank_variance: .state.bank_variance, total_variance: .state.total_variance}'

# The memo is free text from the model — its wording varies, its presence does not.
soat get-orchestration-run --orchestration-run-id "$EXCEPTION_RUN_ID" \
| jq -r '.state.memo'

EXCEPTION_VARIANCE=$(soat get-orchestration-run --orchestration-run-id "$EXCEPTION_RUN_ID" | jq -r '.state.total_variance')
echo "EXCEPTION_VARIANCE: $EXCEPTION_VARIANCE"

Expected output once the run settles:

{
"status": "completed",
"bank_variance": 1250,
"total_variance": 1250
}

Step 7 — Put the close on a schedule​

Create a manual trigger to fire the pass on demand and a schedule trigger for 02:00 UTC on the first of each month. Firing returns a terminal firing record whose result.result_id is the orchestration run it started.

MANUAL_TRIGGER_ID=$(soat create-trigger \
--project-id "$PROJECT_ID" \
--name "run-close-now" \
--type manual \
--target-type orchestration \
--target-id "$CLOSE_ORCH_ID" \
--input '{
"ledger": { "cash": 128450.25, "ar": 64200.00, "ap": 31775.50 },
"statements": { "bank": 128450.25, "ar_subledger": 64200.00, "ap_subledger": 31775.50 },
"tolerance": 1
}' | jq -r '.id')

FIRING=$(soat fire-trigger --trigger-id "$MANUAL_TRIGGER_ID" --input '{"period":"2026-09"}')
printf '%s\n' "$FIRING" | jq '{status, result}'

# 02:00 UTC on the 1st of every month. The scheduler fires this one; you do not.
MONTHLY_TRIGGER_ID=$(soat create-trigger \
--project-id "$PROJECT_ID" \
--name "month-end-close" \
--type schedule \
--target-type orchestration \
--target-id "$CLOSE_ORCH_ID" \
--cron "0 2 1 * *" \
--input '{
"ledger": { "cash": 128450.25, "ar": 64200.00, "ap": 31775.50 },
"statements": { "bank": 128450.25, "ar_subledger": 64200.00, "ap_subledger": 31775.50 },
"tolerance": 1
}' | jq -r '.id')

soat get-trigger --trigger-id "$MONTHLY_TRIGGER_ID" | jq '{name, cron, next_fire_at}'

The fire-time input is merged over the trigger's static input, so the period is supplied per firing while the figures stay on the trigger. See Triggers — Schedules and Misfire Coalescing.


Step 8 — Define the close period as a workflow​

The period can sit in review for days and be sent back, so it is a workflow: named states and named transitions.

Two transitions carry the governance:

  • request_rework moves controller_review → reconciling, backward. A DAG cannot express this; it is why the period is a workflow, not another orchestration.
  • close_period carries both gates: a JSON Logic guard over the task (payload.reconciled must be true) and requires_approval: true, which parks a human decision instead of moving the task. The deterministic check runs first.

controller_review is a human state: it never dispatches automation and parks until someone fires a transition. Its stalled_after emits a tasks.stalled event after two days — see Stall detection.

CLOSE_STATES='[
{ "name": "open", "initial": true },
{ "name": "reconciling" },
{ "name": "controller_review", "kind": "human", "stalled_after": 172800 },
{ "name": "closed", "terminal": true }
]'

CLOSE_TRANSITIONS='[
{ "name": "start_reconciliation", "from": ["open"], "to": "reconciling" },
{ "name": "submit_for_review", "from": ["reconciling"], "to": "controller_review" },
{ "name": "request_rework", "from": ["controller_review"], "to": "reconciling" },
{ "name": "close_period", "from": ["controller_review"], "to": "closed",
"guard": { "==": [ { "var": "task.payload.reconciled" }, true ] },
"requires_approval": true }
]'

WORKFLOW_ID=$(soat create-workflow \
--project-id "$PROJECT_ID" \
--name "Close Period" \
--description "The life of one accounting period" \
--states "$CLOSE_STATES" \
--transitions "$CLOSE_TRANSITIONS" | jq -r '.id')
echo "WORKFLOW_ID: $WORKFLOW_ID"

Step 9 — Open the period and record what the run found​

Create a task for the period and move it into reconciling. Then write the variance the exception run produced into the caller-owned payload and submit it for review.

TASK_ID=$(soat create-task \
--project-id "$PROJECT_ID" \
--workflow-id "$WORKFLOW_ID" \
--title "Close 2026-08" \
--payload '{"period":"2026-08","reconciled":false}' | jq -r '.id')
echo "TASK_ID: $TASK_ID"

soat transition-task --task-id "$TASK_ID" --transition start_reconciliation | jq '{state}'

# Carry the total from the exception run into the card, then hand it to the controller.
soat update-task --task-id "$TASK_ID" \
--payload '{"total_variance": '"$EXCEPTION_VARIANCE"', "reconciled": false}' | jq '{payload}'

soat transition-task --task-id "$TASK_ID" --transition submit_for_review | jq '{state, status}'

Expected output from the last command:

{
"state": "controller_review",
"status": "open"
}

Step 10 — The controller sends it back​

payload.reconciled is false, so the controller fires request_rework, moving the period backward into reconciling. After the corrected pass ties out, the card goes forward again with reconciled: true.

Order matters: an approval-gated transition parks an approval item first and evaluates the guard when that item resolves (see Approval-gated transitions), and no other transition may fire while an approval is pending, so the rework move must happen before the sign-off is requested.

# Backward move — the thing a DAG cannot express.
soat transition-task --task-id "$TASK_ID" --transition request_rework | jq '{state}'

# The corrected pass ties out; record it and hand the card back to the controller.
soat update-task --task-id "$TASK_ID" \
--payload '{"total_variance": 0, "reconciled": true}' | jq '{payload}'

soat transition-task --task-id "$TASK_ID" --transition submit_for_review | jq '{state}'

After request_rework the state is reconciling; after submit_for_review it is controller_review again. See Transition for how a guard is evaluated against {task, transition, principal}.


Step 11 — Sign off: the human decides, the guard has the last word​

payload.reconciled is now true. Firing close_period does not move the card: requires_approval: true parks a pending item in the Approvals queue and the task exposes pending_transition until someone resolves it.

The guard is re-evaluated at resolution time as the approval principal. It is the same queue and audit trail an orchestration approval node uses; each item carries an origin.

# Parks an approval instead of closing the period.
soat transition-task --task-id "$TASK_ID" --transition close_period \
| jq '{state, pending_transition}'

CLOSE_APPROVAL_ID=$(soat list-approvals --project-id "$PROJECT_ID" --status pending \
| jq -r '.data[0].id')
echo "CLOSE_APPROVAL_ID: $CLOSE_APPROVAL_ID"

soat approve-approval --approval-id "$CLOSE_APPROVAL_ID" | jq '{status, resolved_by_principal_type, resolved_by_principal_id}'

soat get-task --task-id "$TASK_ID" | jq '{state, status}'

Expected output from the last command — entering a terminal state closes the task:

{
"state": "closed",
"status": "closed"
}

To reject instead, soat reject-approval --approval-id "$CLOSE_APPROVAL_ID" --reason "Bank confirmation missing." clears the gate and leaves the period in controller_review.


Step 12 — Read the audit trail​

Every transition is recorded with who fired it and when. The closing move is attributed to the approval principal rather than to whoever typed the command.

soat get-task-history --task-id "$TASK_ID" \
| jq '[.data[] | {transition, from_state, to_state, principal_type}]'

You should see start_reconciliation, submit_for_review, request_rework, submit_for_review again, and close_period; the backward move is preserved. Project-wide activity: Activity and Audit Log.


Next Steps​