Counters and stages¶
Four things are tracked, and each answers a question the others cannot.
| Counts or holds | Answers | |
|---|---|---|
status |
Where the item sits at rest | What is in the client's queue |
brain_calls |
Times the brain was invoked | Is this looping? |
model_calls |
Actual model invocations | What did this item cost? |
| Stage trace | Each stage, with a timestamp | Where did it fail, and what is slow? |
brain_calls, not "turns"¶
Every call to the brain increments it. Classify is call 1, drafting is call 2, deciding what happens after a send is call 3.
Its only purpose is a ceiling. max_brain_calls halts an item that is looping, so it stops itself rather than burning money silently. The number is never shown to a client.
The stages inside a brain call, being classify, resolve and decide, are not separate calls. They are stages within one.
model_calls is the cost figure¶
A brain call is a poor proxy for cost, because it may make zero, one or two model calls.
| Situation | brain_calls |
model_calls |
|---|---|---|
| Idempotency hit, stored decision returned | 1 | 0 |
| Classify | 1 | 1 |
| Classify, then draft | 2 | 2 |
| A parse retry | 1 | 2 |
So an item that hit the idempotency check twenty times cost nothing, while an item with five drafts cost five times. brain_calls hides that and model_calls does not.
With tokens_in and tokens_out accumulated alongside, cost per item becomes a fact rather than an estimate.
It also answers the expensive question on its own. If a brain call crashed, comparing model_calls before and after tells you whether a model call was already paid for, without needing a finer trace.
The stage trace¶
One row per stage, written as it is reached, plus the same value denormalised onto the work item as current_stage.
| Stage | Recorded by | Reached when |
|---|---|---|
captured |
n8n | The item exists and the message row is written |
classified |
Brain | The model returned and parsed |
resolved |
Brain | Customer and job lookups finished, or were skipped |
decided |
Brain | Rules ran and an action was validated |
executed |
n8n | The branch completed |
awaiting_human |
n8n | An approval record was written |
reconciled |
n8n | The pre-send check ran |
completed |
n8n | The exchange ended |
Each row carries the stage, the brain call it belongs to, whether it succeeded, and a timestamp.
Write each stage as it is reached, never the whole trace at the end
A trace that only lands on success tells you nothing about failures, which is the one case it exists for.
Why both the trace and current_stage¶
The live funnel is the reason. "How many items are at each stage right now" against the trace alone means a per-item max across every row ever written, which degrades as the table grows. Denormalised, it is a simple group-by.
| Query | From |
|---|---|
| How many items are at each stage right now | work_item.current_stage, grouped |
How many passed through classified in the last hour |
stage_trace, counted |
| Where did this item fail | stage_trace, last row |
| Which stage is slow | stage_trace, gaps between rows |
The trace answers historical questions. The denormalised column answers the live one.
Why stage-level and not step-level¶
An earlier draft of this design traced every step inside a stage: masked, model_called, parsed, and so on. That is more detail than Postgres should carry, for two reasons.
The expensive question is already answered. "Did we pay before it crashed" comes from model_calls, not from a step row.
Sub-stage timing belongs in the tracing tool. Whether masking or the model call is the slow part is a profiling question, and LangSmith or Langfuse exists for exactly that. Rebuilding it in Postgres duplicates a tool already being paid for.
| Postgres stage trace | Tracing tool | |
|---|---|---|
| Granularity | Stage | Every step, with payloads |
| Retention | Long | Short |
| Queryable alongside work items | Yes | No |
| Cost | A few rows | Included |
Postgres answers "what happened to this item". The tracing tool answers "why was this call slow". Different questions, different tools.
What the stage trace answers¶
| Question | From |
|---|---|
| Where did it fail? | The last stage recorded |
| Which stage is slow? | Duration between consecutive stages |
| Is resolution or classification the bottleneck? | Compare the two spans |
| Did we pay before it crashed? | model_calls, not the trace |
status, by contrast¶
Coarse, business-meaningful, and what the client review app shows.
status |
Means |
|---|---|
new |
Created, not yet stamped |
assessing |
The brain has it |
awaiting_approval |
A gated action is pending a human |
sent_by_us |
Approved and sent |
closed_by_human |
The human handled it without using our draft |
closed_manual |
The human replied themselves before we got there |
closed_no_action |
Nothing was needed |
attention_reason is separate, nullable, and says why an item needed care. A flagged item can still end sent_by_us, which is why the two are not one field (action catalogue).
An item is inside Resolve for a fraction of a second, so it never sits there. status is for where things rest. The stage trace is for where they were.
A worked example¶
One exchange, end to end.
| Event | Who | brain_calls |
model_calls |
Stage |
|---|---|---|---|---|
| Email arrives, item created | n8n | 0 | 0 | captured |
| Brain: classify, resolve, decide | Brain | 1 | 1 | classified, resolved, decided |
Switch matches draft_reply |
n8n | 1 | 1 | |
| Brain: draft | Brain | 2 | 2 | classified for call 2 |
| Draft and approval written | n8n | 2 | 2 | executed, awaiting_human |
| Human approves | Human | 2 | 2 | |
| Reconcile and send | n8n | 2 | 2 | reconciled |
| Brain: what next? | Brain | 3 | 3 | decided |
close_item |
n8n | 3 | 3 | completed |
Three brain calls, three model calls, around ten stage rows.
Stuck items¶
Where the counters combine.
| Symptom | Likely cause |
|---|---|
status: new for more than a few minutes |
The brain never got called. Check the Console |
Last stage classified, nothing since |
Resolve is hanging on a client system lookup |
status: awaiting_approval past its expiry |
Expiry did not fire, or the approver is unreachable |
brain_calls climbing with no stage progress |
A retry loop. The ceiling should catch it |
An item in new with a null config_version is the clearest signal, because n8n created it and the brain never arrived. That is a system failure, ours to fix, and it should never surface to the client.