Skip to content

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.