# Close the Monthly Books

> Compose an orchestration, a workflow, a trigger, and an approval into one governed month-end close process.

A month-end close spans four layers:

- **Reconciliation pass** — an [orchestration](/docs/modules/orchestrations):
  accounts reconcile in parallel, variances converge, a branch decides.
- **Close period** — a [workflow](/docs/modules/workflows): lives for days and
  moves backward when the controller sends it back.
- **Cadence** — a [trigger](/docs/modules/triggers) on the first of the month.
- **Sign-off** — an [approval](/docs/modules/approvals) recorded for audit.

Every routing decision is arithmetic evaluated by
[JSON Logic](https://jsonlogic.com); the [agent](/docs/modules/agents) only
writes the controller a readable note.

Read [Conditional Branching](/docs/tutorials/conditional-orchestration) and
[Orchestration Control Flow](/docs/tutorials/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](/docs/modules/tools) nodes instead of
> run input.

## Prerequisites

- SOAT running locally. Follow the [Quick Start](/docs/getting-started) guide to bring the stack up with Docker Compose.
- New to SOAT? Read [Key Concepts](/docs/getting-started/concepts) for projects, agents, orchestrations, and tasks.
- CLI installed and configured, or SDK set up. See [CLI](/docs/cli) or [SDK](/docs/sdk).
- [Ollama](https://ollama.com) reachable by the server, for the one agent node.
- For production hardening (secrets, env vars), see [Configuration](/docs/self-hosting/configuration).
- Server is at `http://localhost:5047`.

**CLI**

```bash
export SOAT_BASE_URL=http://localhost:5047
```

**SDK**

```ts
import { SoatClient } from '@soat/sdk';
```

**curl**

```bash
export SOAT_BASE_URL=http://localhost:5047
```

---

## Step 1 — Log in as admin

Admin is the built-in superuser role. See [Users](/docs/modules/users#examples) for authentication details.

**CLI**

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

**SDK**

```ts
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,
});
```

**curl**

```bash
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](/docs/modules/projects#examples).

**CLI**

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

**SDK**

```ts
const { data: project } = await adminSoat.projects.createProject({
  body: { name: 'Monthly Close' },
});
const PROJECT_ID = project.id;
```

**curl**

```bash
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](/docs/modules/ai-providers#examples) and one
[agent](/docs/modules/agents#examples). 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](/docs/tutorials/connect-third-party-llms).

**CLI**

```bash
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"
```

**SDK**

```ts
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;
```

**curl**

```bash
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.

```mermaid
flowchart TB
    bank_recon --> total_variance
    ar_recon --> total_variance
    ap_recon --> total_variance
    total_variance["total_variance<br/>join barrier: all"] --> gate_check{gate_check}
    gate_check -->|clean| clean_summary
    gate_check -->|exception| draft_memo
```

| 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](/docs/modules/orchestrations#node-types) for the
node reference.

**CLI**

```bash
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:

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

**SDK**

```ts
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;
```

**curl**

```bash
# 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](/docs/modules/orchestrations#examples) 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**

```bash
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:

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

`draft_memo` appears with `"status": "skipped"`.

**SDK**

```ts
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]));
```

**curl**

```bash
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](/docs/modules/agents#examples) writes the memo. `clean_summary` is the
skipped node.

**CLI**

```bash
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:

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

**SDK**

```ts
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);
```

**curl**

```bash
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](/docs/modules/triggers#examples) 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**

```bash
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](/docs/modules/triggers#schedules-and-misfire-coalescing).

**SDK**

```ts
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);
```

**curl**

```bash
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](/docs/modules/workflows#examples): 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](/docs/modules/workflows#stall-detection).

**CLI**

```bash
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"
```

**SDK**

```ts
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;
```

**curl**

```bash
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](/docs/modules/workflows#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**

```bash
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:

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

**SDK**

```ts
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
```

**curl**

```bash
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](/docs/modules/workflows#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**

```bash
# 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](/docs/modules/workflows#transition) for how a guard is evaluated
against `{task, transition, principal}`.

**SDK**

```ts
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**

```bash
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](/docs/modules/approvals#examples) 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](/docs/tutorials/approval-gate) uses; each item carries an
`origin`.

**CLI**

```bash
# 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:

```json
{
  "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`.

**SDK**

```ts
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**

```bash
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**

```bash
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](/docs/modules/activity) and
[Audit Log](/docs/modules/audit-log).

**SDK**

```ts
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**

```bash
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](/docs/tutorials/formations) and [Create an Agent Squad](/docs/tutorials/create-an-agent-squad).
- A state's `on_enter` can dispatch the reconciliation orchestration when the period enters `reconciling`: [Workflows & Tasks](/docs/modules/workflows).
- Add `delay`, `poll`, and `loop` steps with [Orchestration Control Flow](/docs/tutorials/orchestration-control-flow).
- Gate a real posting call with [Guardrails](/docs/tutorials/gate-a-tool-with-guardrails).
- Route `tasks.stalled` to a channel with [Webhooks](/docs/modules/webhooks).
