Skip to main content

Close the Monthly Books

Every company closes its books. The process is a good fit for SOAT because it is made of parts that belong to different layers, and this tutorial is where those layers meet:

  • The reconciliation pass is a pipeline that runs and ends — an orchestration. Several accounts reconcile in parallel, the variances converge, and a branch decides what happens next.
  • The close period is an entity that lives for days and can move backward when the controller sends it back — a workflow.
  • The cadence is the first of the month — a trigger.
  • The sign-off is a human decision recorded for audit — an approval.

The single most important design choice here is where the model sits. Every routing decision in the graph is arithmetic evaluated by JSON Logic — whether a variance clears tolerance is a subtraction and a comparison, not a judgement. The agent is used for exactly one thing the arithmetic cannot do: writing the controller a readable note about what to investigate. The books never depend on what a model decides.

You will:

  1. Build a reconciliation orchestration whose branch is decided by arithmetic.
  2. Run it clean, then run it against books that do not balance.
  3. Put it on a monthly schedule with a trigger.
  4. Model the close period as a workflow with a backward transition.
  5. Close the period behind a guard and a human approval, then read the audit trail.

This tutorial assumes you already know how a graph is wired. If you do not, read Conditional Branching and Orchestration Control Flow first — this one composes those pieces rather than re-teaching them.

The figures here are fixtures chosen to make the arithmetic legible. This tutorial teaches the mechanics of a governed process; it is not accounting guidance, and 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's whole job is to turn a number into a sentence a controller can act on — it never decides whether the books balance.

Note what the agent does not have: an output_schema. Nothing downstream parses its text, so a weaker model cannot break the graph. That is the general rule for putting a model inside a deterministic process — give it the last word on wording, never on control flow.

This tutorial uses a local Ollama provider so it can run without external credentials. 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. Three reconciliations have no incoming edges, so they are all start nodes and run in the same round — in parallel. Their edges share an activation_group with activation_condition: "all", which makes total_variance a join barrier: it waits for all three.

bank_recon ─┐
ar_recon ─┼─(all)─► total_variance ─► gate_check ─┬─clean──────► clean_summary
ap_recon ─┘ └─exception──► draft_memo
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

Two things to notice in the JSON Logic. First, JSON Logic has no absolute-value operator, so each reconciliation uses if to pick whichever subtraction order is positive. Second, run input and run state are different namespaces: read input as {"var": "input.tolerance"}, and read a state key an upstream node wrote as a bare {"var": "total_variance"}. A flat reference is never satisfied by run input, which is why the two forms are not interchangeable.

validate-orchestration statically checks the graph — unique ids, edges that resolve, acyclicity, and every {"var": ...} reference reachable from an upstream writer — without persisting anything. Run it before you create, and again in CI whenever a graph changes. See Orchestrations for the full 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, errors, 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,
"errors": [],
"warnings": []
}

Step 5 — Run a clean close

Start a run with books that balance. All three reconciliations return 0, the total is 0, and 0 <= 1 routes down the clean edge. draft_memo is never reached, so it is recorded as skipped — no model was called at all on this path.

A run starts asynchronously. start-orchestration-run enqueues the run and returns immediately with status: "queued" and an empty state — a worker drives it. Read the results from get-orchestration-run once the run reaches a terminal status, rather than from the start response. 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-id "$CLOSE_ORCH_ID" --orchestration-run-id "$CLEAN_RUN_ID" | jq -e '.status == "succeeded"'

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

soat get-orchestration-run --orchestration-id "$CLOSE_ORCH_ID" --orchestration-run-id "$CLEAN_RUN_ID" \
| jq '[.node_executions[] | {node_id, status}]'

Expected output once the run settles:

{
"status": "succeeded",
"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, and gate_check routes down exception — so the agent runs and writes the memo. This time clean_summary is the skipped node.

The variance is computed, not judged. Change the tolerance or the figures and the branch changes with them, identically on every run.

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-id "$CLOSE_ORCH_ID" --orchestration-run-id "$EXCEPTION_RUN_ID" | jq -e '.status == "succeeded"'

soat get-orchestration-run --orchestration-id "$CLOSE_ORCH_ID" --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-id "$CLOSE_ORCH_ID" --orchestration-run-id "$EXCEPTION_RUN_ID" \
| jq -r '.state.memo'

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

Expected output once the run settles:

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

Step 7 — Put the close on a schedule

A close has a cadence, which makes it the natural home for a trigger. 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, which is why the period can be 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 reconciliation pass ends. The period does not — it can sit in review for days and be sent back. That is a workflow: named states, and named transitions between them.

Two transitions carry the governance:

  • request_rework moves controller_reviewreconciling, i.e. backward. A DAG cannot express this at all; it is the reason the period is a workflow and not another orchestration.
  • close_period carries both gates. Its guard is JSON Logic over the task — the period cannot close unless payload.reconciled is true — and requires_approval: true parks a human decision instead of moving the task. The deterministic check runs first, and the human is only asked about something that already passed it.

controller_review is a human state, so it never dispatches automation; it parks until someone fires a transition. Its stalled_after emits a tasks.stalled event if the period sits there longer than 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 — one card, one period — and move it into reconciling. Then write the variance the exception run produced into the card's payload and submit it for review. The payload is caller-owned, so this is where the pipeline's finding becomes the period's state.

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

The books do not balance — payload.reconciled is false — so the controller does not try to close the period. They fire request_rework, which moves it backward into reconciling. The team finds the missing deposit, the corrected pass ties out, and the card goes forward again with reconciled: true.

Order matters here, and not for the reason you might expect. A guard and requires_approval on the same transition are not checked at the same moment: firing an approval-gated transition parks an approval item first and evaluates the guard when that item resolves (see Approval-gated transitions). So firing close_period now would not be rejected — it would park a decision against books that do not tie out, and the guard would only refuse later, at resolution. While an approval is pending the task also exposes pending_transition and no other transition may fire, so the rework move has to happen before the sign-off is requested, not after.

# 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, so both gates will let the period close. 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 then re-evaluated at resolution time, as the approval principal. That ordering is worth internalising: the human decision is collected first and the deterministic check is applied last, so a sign-off cannot be banked while the books tie out and then cashed after they stop tying out. The approval is a request to close; the guard decides whether closing is still legal.

This is the same queue, the same endpoints, and the same audit trail that an orchestration approval node uses. Each item carries an origin, so a reviewer works one queue regardless of which layer raised the request.

# 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}'

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

The period's history is a first-class record: every transition, who fired it, and when. The rework loop is visible, and the closing move is attributed to the approval principal rather than to whoever typed the command — which is exactly what an auditor asks for.

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

You should see start_reconciliation, submit_for_review, request_rework, submit_for_review again, and close_period — the backward move preserved in the record, not overwritten. Project-wide activity is available through Activity and the Audit Log.


How It Works

  • The model never decides control flow. Every branch is JSON Logic over numbers, so the same books always produce the same route. The agent writes one memo on one branch, declares no output_schema, and nothing downstream parses it. A slower or weaker model changes the prose and nothing else.
  • A pipeline that ends, and an entity that lives. The reconciliation pass is an orchestration because it starts, fans out, converges, and terminates. The period is a workflow because it persists across days and moves backward. Trying to model the second as a DAG is what forces people into glue code; request_rework is the transition that makes the distinction concrete.
  • Two gates, and the human is asked first. On an approval-gated transition the requires_approval park happens at fire time and the guard is evaluated when the item resolves — not the other way round. The consequence is the useful part: a sign-off cannot be collected while the books balance and then applied after they stop balancing, because the deterministic check runs last. If you want a cheap check to run before anyone is paged, it belongs on the transition that reaches the review state, or in the graph — not on the gated transition itself.
  • A run is asynchronous. start-orchestration-run enqueues and returns queued with an empty state; a worker drives it. Anything that reads state, output, or node_executions has to poll get-orchestration-run until the run is terminal. A trigger firing is the exception — it runs the target synchronously and hands back the finished record.
  • Joins are explicit. activation_group with activation_condition: "all" is what makes total_variance wait for all three reconciliations. Without it, it would run as soon as the first one finished and sum whatever had landed.
  • Input and state are separate namespaces. {"var": "input.tolerance"} reads run input; a bare {"var": "total_variance"} reads a state key an upstream node wrote. A flat reference is never satisfied from run input, and validate-orchestration catches the mistake before a run does.
  • One approvals queue, several producers. The item here came from a workflow transition; in Approval Gates an equivalent item comes from an orchestration node. Consumers read origin instead of branching on the producer.

Next Steps

  • Deploy this whole stack declaratively — agents, orchestration, and workflow in one document — with Formations and Create an Agent Squad.
  • Automate the handoff between the layers: a state's on_enter can dispatch the reconciliation orchestration when the period enters reconciling. See Workflows & Tasks.
  • Add delay, poll, and loop steps to the pass — for example, waiting on a bank feed — with Orchestration Control Flow.
  • Gate a real posting call behind a tripwire with Guardrails.
  • Route the tasks.stalled event to a channel with Webhooks so a period that sits in review too long pages someone.