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.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
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.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
Every resource below lives inside one project.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Monthly Close" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Monthly Close' },
});
const PROJECT_ID = project.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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.
- CLI
- SDK
- curl
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"
const { data: aiProvider } = await adminSoat.aiProviders.createAiProvider({
body: {
project_id: PROJECT_ID,
name: 'Local Ollama',
provider: 'ollama',
default_model: 'qwen2.5:0.5b',
},
});
const AI_PROVIDER_ID = aiProvider.id;
const { data: memoAgent } = await adminSoat.agents.createAgent({
body: {
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,
},
});
const MEMO_AGENT_ID = memoAgent.id;
AI_PROVIDER_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/ai-providers" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","name":"Local Ollama","provider":"ollama","default_model":"qwen2.5:0.5b"}' \
| jq -r '.id')
MEMO_AGENT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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.
| Node | Type | Purpose |
|---|---|---|
bank_recon | transform | Absolute difference between ledger cash and the bank statement |
ar_recon | transform | Absolute difference between the AR control account and its subledger |
ap_recon | transform | Absolute difference between the AP control account and its subledger |
total_variance | transform | Sums the three variances once all have landed |
gate_check | condition | Emits "clean" or "exception" by comparing the total against tolerance |
clean_summary | transform | The clean branch — records that the period tied out |
draft_memo | agent | The 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.
- CLI
- SDK
- curl
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": []
}
const closeNodes = [
{
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' } },
},
];
const closeEdges = [
{
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' },
];
const { data: validation } =
await adminSoat.orchestrations.validateOrchestration({
body: { nodes: closeNodes, edges: closeEdges },
});
console.log('Valid:', validation.valid, validation.problems);
const { data: closeOrch } =
await adminSoat.orchestrations.createOrchestration({
body: {
project_id: PROJECT_ID,
name: 'Month-End Reconciliation',
description:
'Reconciles bank, AR and AP in parallel and routes on total variance',
nodes: closeNodes,
edges: closeEdges,
},
});
const CLOSE_ORCH_ID = closeOrch.id;
# The nodes/edges JSON is identical to the CLI tab; keep it in files to stay readable.
curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestrations/validate" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"nodes":'"$CLOSE_NODES"',"edges":'"$CLOSE_EDGES"'}' \
| jq '{valid, problems, warnings}'
CLOSE_ORCH_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestrations" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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"
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.
- CLI
- SDK
- curl
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".
const { data: queued } = await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: CLOSE_ORCH_ID,
input: {
period: '2026-07',
ledger: { cash: 128450.25, ar: 64200.0, ap: 31775.5 },
statements: {
bank: 128450.25,
ar_subledger: 64200.0,
ap_subledger: 31775.5,
},
tolerance: 1,
},
},
});
console.log('Status on start:', queued.status); // "queued" — a worker drives it
// A run is asynchronous: poll until it reaches a terminal status before
// reading `state`.
const waitForRun = async (runId: string) => {
for (let attempt = 0; attempt < 120; attempt += 1) {
const { data: run } = await adminSoat.orchestrations.getOrchestrationRun({
path: { orchestration_run_id: runId },
});
if (['completed', 'failed', 'cancelled', 'expired'].includes(run.status)) {
return run;
}
await new Promise((resolve) => {
return setTimeout(resolve, 1000);
});
}
throw new Error(`Run ${runId} never settled`);
};
const cleanRun = await waitForRun(queued.id);
console.log('Status:', cleanRun.status);
console.log('Total variance:', cleanRun.state.total_variance);
console.log('Note:', cleanRun.state.close_note);
const { data: cleanExecutions } =
await adminSoat.orchestrations.listOrchestrationRunNodeExecutions({
path: { orchestration_run_id: cleanRun.id },
});
console.log(cleanExecutions.data.map((n) => [n.node_id, n.status]));
CLEAN_RUN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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}}')
CLEAN_RUN_ID=$(printf '%s\n' "$CLEAN_RUN" | jq -r '.id')
until curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$CLEAN_RUN_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -e '.status == "completed"' > /dev/null; do sleep 1; done
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$CLEAN_RUN_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '{status, total_variance: .state.total_variance, close_note: .state.close_note}'
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$CLEAN_RUN_ID/node-executions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {node_id, status}]'
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.
- CLI
- SDK
- curl
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
}
const { data: exceptionQueued } =
await adminSoat.orchestrations.startOrchestrationRun({
body: {
orchestration_id: CLOSE_ORCH_ID,
input: {
period: '2026-08',
ledger: { cash: 128450.25, ar: 64200.0, ap: 31775.5 },
statements: {
bank: 127200.25,
ar_subledger: 64200.0,
ap_subledger: 31775.5,
},
tolerance: 1,
},
},
});
// Reuses waitForRun from the previous step. This path calls the model, so it
// settles more slowly than the clean run.
const exceptionRun = await waitForRun(exceptionQueued.id);
console.log('Bank variance:', exceptionRun.state.bank_variance); // 1250
console.log('Memo:', exceptionRun.state.memo);
EXCEPTION_RUN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/orchestration-runs" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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}}')
EXCEPTION_RUN_ID=$(printf '%s\n' "$EXCEPTION_RUN" | jq -r '.id')
until curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$EXCEPTION_RUN_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -e '.status == "completed"' > /dev/null; do sleep 1; done
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$EXCEPTION_RUN_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '{status, bank_variance: .state.bank_variance, total_variance: .state.total_variance}'
curl -s "$SOAT_BASE_URL/api/v1/orchestration-runs/$EXCEPTION_RUN_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.state.memo'
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.
- CLI
- SDK
- curl
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.
const staticInput = {
ledger: { cash: 128450.25, ar: 64200.0, ap: 31775.5 },
statements: {
bank: 128450.25,
ar_subledger: 64200.0,
ap_subledger: 31775.5,
},
tolerance: 1,
};
const { data: manualTrigger } = await adminSoat.triggers.createTrigger({
body: {
project_id: PROJECT_ID,
name: 'run-close-now',
type: 'manual',
target_type: 'orchestration',
target_id: CLOSE_ORCH_ID,
input: staticInput,
},
});
const { data: firing } = await adminSoat.triggers.fireTrigger({
path: { trigger_id: manualTrigger.id },
body: { input: { period: '2026-09' } },
});
console.log(firing.status, firing.result);
const { data: monthlyTrigger } = await adminSoat.triggers.createTrigger({
body: {
project_id: PROJECT_ID,
name: 'month-end-close',
type: 'schedule',
target_type: 'orchestration',
target_id: CLOSE_ORCH_ID,
cron: '0 2 1 * *',
input: staticInput,
},
});
console.log(monthlyTrigger.next_fire_at);
MANUAL_TRIGGER_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/triggers" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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')
curl -s -X POST "$SOAT_BASE_URL/api/v1/triggers/$MANUAL_TRIGGER_ID/fire" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"input":{"period":"2026-09"}}' | jq '{status, result}'
MONTHLY_TRIGGER_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/triggers" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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')
curl -s "$SOAT_BASE_URL/api/v1/triggers/$MONTHLY_TRIGGER_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{name, cron, next_fire_at}'
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_reworkmovescontroller_review→reconciling, backward. A DAG cannot express this; it is why the period is a workflow, not another orchestration.close_periodcarries both gates: a JSON Logicguardover the task (payload.reconciledmust betrue) andrequires_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.
- CLI
- SDK
- curl
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"
const { data: workflow } = await adminSoat.workflows.createWorkflow({
body: {
project_id: PROJECT_ID,
name: 'Close Period',
description: 'The life of one accounting period',
states: [
{ name: 'open', initial: true },
{ name: 'reconciling' },
{ name: 'controller_review', kind: 'human', stalled_after: 172800 },
{ name: 'closed', terminal: true },
],
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,
},
],
},
});
const WORKFLOW_ID = workflow.id;
WORKFLOW_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/workflows" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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.
- CLI
- SDK
- curl
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"
}
const { data: task } = await adminSoat.tasks.createTask({
body: {
project_id: PROJECT_ID,
workflow_id: WORKFLOW_ID,
title: 'Close 2026-08',
payload: { period: '2026-08', reconciled: false },
},
});
const TASK_ID = task.id;
await adminSoat.tasks.transitionTask({
path: { task_id: TASK_ID },
body: { transition: 'start_reconciliation' },
});
await adminSoat.tasks.updateTask({
path: { task_id: TASK_ID },
body: {
payload: {
total_variance: exceptionRun.state.total_variance,
reconciled: false,
},
},
});
const { data: submitted } = await adminSoat.tasks.transitionTask({
path: { task_id: TASK_ID },
body: { transition: 'submit_for_review' },
});
console.log(submitted.state); // controller_review
TASK_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/tasks" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","workflow_id":"'"$WORKFLOW_ID"'","title":"Close 2026-08","payload":{"period":"2026-08","reconciled":false}}' \
| jq -r '.id')
curl -s -X POST "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"transition":"start_reconciliation"}' | jq '{state}'
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"payload":{"total_variance":'"$EXCEPTION_VARIANCE"',"reconciled":false}}' | jq '{payload}'
curl -s -X POST "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"transition":"submit_for_review"}' | jq '{state, status}'
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.
- CLI
- SDK
- curl
# 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}.
await adminSoat.tasks.transitionTask({
path: { task_id: TASK_ID },
body: { transition: 'request_rework' },
});
await adminSoat.tasks.updateTask({
path: { task_id: TASK_ID },
body: { payload: { total_variance: 0, reconciled: true } },
});
const { data: resubmitted } = await adminSoat.tasks.transitionTask({
path: { task_id: TASK_ID },
body: { transition: 'submit_for_review' },
});
console.log(resubmitted.state); // controller_review
curl -s -X POST "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"transition":"request_rework"}' | jq '{state}'
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"payload":{"total_variance":0,"reconciled":true}}' | jq '{payload}'
curl -s -X POST "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"transition":"submit_for_review"}' | jq '{state}'
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.
- CLI
- SDK
- curl
# 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.
const { data: parked } = await adminSoat.tasks.transitionTask({
path: { task_id: TASK_ID },
body: { transition: 'close_period' },
});
console.log('Parked on:', parked.pending_transition);
const { data: pending } = await adminSoat.approvals.listApprovals({
query: { project_id: PROJECT_ID, status: ['pending'] },
});
const CLOSE_APPROVAL_ID = pending.data[0].id;
await adminSoat.approvals.approveApproval({
path: { approval_id: CLOSE_APPROVAL_ID },
body: {},
});
const { data: closed } = await adminSoat.tasks.getTask({
path: { task_id: TASK_ID },
});
console.log(closed.state, closed.status); // closed closed
curl -s -X POST "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/transitions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"transition":"close_period"}' | jq '{state, pending_transition}'
CLOSE_APPROVAL_ID=$(curl -s "$SOAT_BASE_URL/api/v1/approvals?project_id=$PROJECT_ID&status=pending" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.data[0].id')
curl -s -X POST "$SOAT_BASE_URL/api/v1/approvals/$CLOSE_APPROVAL_ID/approve" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' | jq '{status, resolved_by_principal_type, resolved_by_principal_id}'
curl -s "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{state, status}'
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.
- CLI
- SDK
- curl
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.
const { data: page } = await adminSoat.tasks.getTaskHistory({
path: { task_id: TASK_ID },
});
console.log(
page?.data.map((h) => [
h.transition,
h.from_state,
h.to_state,
h.principal_type,
])
);
curl -s "$SOAT_BASE_URL/api/v1/tasks/$TASK_ID/history" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '[.data[] | {transition, from_state, to_state, principal_type}]'
Next Steps
- Deploy agents, orchestration, and workflow in one document with Formations and Create an Agent Squad.
- A state's
on_entercan dispatch the reconciliation orchestration when the period entersreconciling: Workflows & Tasks. - Add
delay,poll, andloopsteps with Orchestration Control Flow. - Gate a real posting call with Guardrails.
- Route
tasks.stalledto a channel with Webhooks.