1. Capture¶
n8n creates or joins the work item, then the brain assesses and decides.
1.1 n8n creates or joins a work item¶
The trigger fires on a new message. n8n writes the raw email to the landing layer untouched, then looks up the thread before deciding what to do.
1.1.1 Finding the work item¶
flowchart LR
M["Email arrives"] --> H["Read its References<br/>and In-Reply-To headers"]
H --> L{"Any Message-ID<br/>known to us?"}
L -->|"yes"| A(["Join that work item"])
L -->|"no"| C{"provider thread ID<br/>matches?"}
C -->|"yes"| A2(["Join. Weaker signal,<br/>record that it was used"])
C -->|"no"| N(["New work item.<br/>Store its Message-ID<br/>as the thread root"])
Look first, then decide. Creating an item and hunting for siblings to merge afterwards is the same operation backwards, and it leaves a window in which a duplicate exists.
Two sources, one search. The arriving email supplies the values, and our own database supplies the match.
| From the email | From our database |
|---|---|
References, In-Reply-To |
The message_id index |
The lookup does not call the mailbox. Every message received and every reply sent has its Message-ID stored, so the incoming chain is matched against that index.
1.1.2 The headers, and where they come from¶
The three headers used here are defined by RFC 5322, the internet message format standard. Every mail system implements them, which is why they work across providers.
| Header | Contains |
|---|---|
Message-ID |
This email's unique identifier |
In-Reply-To |
The Message-ID of the email it replies to |
References |
The Message-IDs of the whole chain before it |
So "read its References and In-Reply-To" in the diagram above is reading the RFC 5322 headers. One mechanism, not two.
The provider's own thread grouping is a separate thing, stored as provider_thread_id (conversationId on Microsoft Graph, threadId on Gmail), and it is not used as the primary key.
Do not use the provider's thread ID as the primary key
Microsoft Graph's conversationId is derived from the conversation topic, so
changing the subject on a reply produces a new conversation.
Worse for this product: when an external party replies, on Gmail, Yahoo
or another organisation's Exchange, Graph frequently assigns a new
conversationId even though the RFC References and In-Reply-To headers
are correct. Every inbound email in email triage is an external party
replying, so this is the normal case rather than an edge case.
Microsoft's own guidance is to link messages by RFC 5322 identifiers rather
than by conversationId, and to persist the standard Message-ID of sent
messages as the thread root. Graph calls that field internetMessageId,
Gmail exposes it in the headers, and we store it as message_id.
| Situation | RFC headers | Provider thread ID |
|---|---|---|
| Reply from the same Exchange org | Intact | Usually intact |
| Reply from an external party | Intact | Often a new ID |
| Subject changed on a reply | Intact | New ID |
| Quoted text trimmed to nothing | Intact | Intact |
| Forward | Often a new chain | Often a new ID |
| Fresh compose about the same job | New item, correctly | New ID |
Cross-provider: Microsoft has conversationId, Google has threadId, IMAP has nothing standard, and each has its own quirks. The RFC headers are the only mechanism that works everywhere, which also keeps the connector simple: one threading implementation rather than one per provider.
Quoted history plays no part. It is unreliable (trimmed, collapsed, or dropped by mobile clients) and in any case contains only the words sent, never the assessment, the approval, or the action taken. That state is ours and lives on the item.
Subject-line matching is not used at all. Two customers both sending "Re: Invoice" would collide.
1.1.3 What gets written¶
| Field | What it means | New item | Existing item |
|---|---|---|---|
source |
Which channel the work arrived on. email today, later form or webhook |
email |
unchanged |
source_ref |
Pointer to the raw untouched copy in the landing layer | landing row ID | unchanged. The new message gets its own row |
status |
Where the item sits in its lifecycle | new |
unchanged, unless it had closed |
brain_calls |
Times the brain has been invoked for this item. A ceiling halts a looping item | 0 |
unchanged here. The brain increments it |
model_calls |
Actual model invocations. The cost figure, since a brain call may make zero, one or two | 0 |
unchanged here. The brain increments it |
config_version |
Which version of the tenant's rules and thresholds this item runs under | null, stamped at 1.2 | unchanged. Keeps what it was stamped with |
catalogue_version |
Which version of the action catalogue this item may draw on | null, stamped at 1.2 | unchanged |
category |
What the message is about. A label for reporting, and it decides nothing on its own | null | unchanged here. The brain may revise it |
priority |
How urgent, mostly set by rules. A property of the item, never an action parameter | null | unchanged here. The brain may revise it |
job_id |
The client's job this relates to, if any. Many items have none | null | unchanged here. Re-resolved every turn at 1.2.2 |
A row in message |
One email, holding its Message-ID, In-Reply-To and References |
Yes, direction: inbound |
Yes, direction: inbound |
The versions row is the important one. An existing item keeps the config and catalogue versions it was stamped with, however long the conversation runs. A rule changed this afternoon does not reach a conversation that started last week (ADR-0006).
A reply arriving on a closed item reopens it rather than starting a new one, since the customer is continuing the same conversation.
Either way, a row is written to message carrying the Message-ID, In-Reply-To and References of the arriving email. That is what a later reply will be matched against.
Deterministic, so no AI and no approval. This is the "no review, no go" boundary in practice: plumbing does not need approval.
The raw email lands before the brain is called. If the brain is down, the email is still captured and the item is created, so an outage never loses inbound mail. A triage product cannot have that failure.
Nothing may act on an unstamped item. An item with a null config_version is not ready, and that is an explicit check rather than an accident of wiring.
1.2 The brain stamps, assesses and decides¶
One call, three stages inside it. n8n passes the work item ID.
| Stage | Does | Cost | Can fail |
|---|---|---|---|
| 1.2.1 Classify | Guardrails, facts, then one model call for category, priority and a proposed action. See the Classify prompt | One model call | Rarely |
| 1.2.2 Resolve | Customer, then job. Only if the facts suggest existing work | A lookup against the client's system | Often, and legitimately |
| 1.2.3 Decide | The remaining rule tiers, then validate the proposal | Deterministic | Rarely |
Keeping them separate matters because they fail differently. Classification is cheap and nearly always works. Resolution needs a client system and can genuinely come back ambiguous or empty, and a resolution failure must not lose the classification.
Rules run at two points¶
Rules constrain, they never decide. The model picks from what is left permitted, so the rules that shape its options must run before it is asked.
| Tier | Runs at | May use |
|---|---|---|
| Guardrails | 1.2.1, before the model | Facts only |
| Vertical, tenant, learned | 1.2.3, after | Facts and observations |
Guardrails are facts only because they have to be. They run before the model, so they cannot key on anything the model has not yet produced. The safety argument points the same way: a guardrail keyed on category would be silently skipped exactly when the model gets it wrong.
Full tiering, effects and client calibration: rules.
flowchart LR
IN(["n8n calls"]) --> P["Token check<br/>Idempotency<br/>Load + stamp"]
P --> C["<b>1.2.1 Classify</b><br/><i>guardrails,<br/>then the model</i>"]
C --> R["<b>1.2.2 Resolve</b><br/><i>lookups</i>"]
R --> D["<b>1.2.3 Decide</b><br/><i>the other rules</i>"]
D --> OUT(["Return<br/>assessment + action"])
classDef llm fill:#7c3aed,stroke:#5b21b6,color:#ffffff
class C llm
Only 1.2.1 is purple, which is the design: one model call per turn. Resolve is lookups and Decide is rules, so neither calls a model.
Two checks run before any stage does work.
Identity comes from the token. A tenant_id in the body is not trusted, and a body that disagrees with its token is rejected rather than reconciled, because a mismatch means something is already wrong (ADR-0032 §6a).
Idempotency is checked before anything else. n8n retries on timeout, and without this a retry produces a second decision and a second draft, having paid for both. The key is work item plus turn number.
flowchart LR
IN(["n8n calls"]) --> TOK{"Tenant from<br/>bearer token"}
TOK -->|"body disagrees"| REJ(["<b>STOP</b><br/>Reject. No work done,<br/>nothing written"])
TOK -->|"ok"| IDEM{"Seen this<br/>item + turn?"}
IDEM -->|"yes"| STORED(["<b>STOP</b><br/>Return the stored decision.<br/>No model call, no charge"])
IDEM -->|"no"| LOAD["Load work item<br/>+ stamp versions"]
LOAD --> NEXT(["Continue to<br/><b>1.2.1 Classify</b>"])
Only one of the three paths continues. A rejected request does no work and writes nothing. A repeat request returns what was already decided, without a model call and without a charge. Only a genuinely new turn reaches the stages.
1.2.1 Classify¶
What the message is, before anything is looked up.
flowchart LR
F["Deterministic facts:<br/>thread · sender · domain<br/>references · dates"] --> G["<b>Guardrails</b><br/>narrow the<br/>permitted actions"]
G --> M["Mask identifiers"]
M --> L["One model call"]
L --> P{"Parses to<br/>Pydantic?"}
P -->|"no, retry once"| L
P -->|"fails twice"| E(["<b>STOP</b><br/>escalate_item"])
P -->|"yes"| U["Unmask"]
U --> NEXT(["Continue to<br/><b>1.2.2 Resolve</b>"])
classDef llm fill:#7c3aed,stroke:#5b21b6,color:#ffffff
class L llm
Only the purple node is a model call. The facts and guardrails before it and the parse after it are ordinary code, which is what makes a retry cheap to reason about: the same masked input goes back in.
Deterministic facts run before the model, and go into the prompt. The model is told the sender is a known customer with three open jobs rather than guessing at it.
Guardrails run here too, narrowing the permitted actions before the prompt is built, so the model chooses from a set that already excludes anything a hard rule forbids. See rules.
What the model is actually asked, including the categories, tie-breaks and the user block layout: the Classify prompt.
Masking happens inside llm-client, so it is the only path to a model and cannot be bypassed by a future engine that forgets to call it.
Parsing to Pydantic¶
The model is asked to return JSON in a fixed shape. Pydantic is a Python library that defines that shape as a class and either produces a valid object or raises an error. Nothing in between.
class Assessment(BaseModel):
category: Literal["enquiry", "quote_request", "job_status", ...]
priority: Literal["low", "normal", "high"]
confidence: float = Field(ge=0, le=1)
summary: str = Field(max_length=280)
That single definition rejects a whole class of failure:
| The model returns | Result |
|---|---|
| A category we never defined | Rejected. Literal permits only the known list |
"priority": "URGENT!!" |
Rejected |
"confidence": 1.4 |
Rejected. Out of range |
| A missing field | Rejected |
| Prose wrapped around the JSON | Rejected |
| Valid JSON in the right shape | A typed object the rest of the code can trust |
The point is that everything downstream can assume the data is well-formed. No defensive checks scattered through the rules engine, no if category not in [...], and no silent handling of a value nobody anticipated.
It is the same discipline as the closed action catalogue, applied to the model's output rather than its choices: constrain the shape so a wrong answer is a known wrong answer.
Never a regular expression and never a bare json.loads. A regex over model output eventually matches something it should not, and json.loads accepts any valid JSON, including a well-formed object with entirely the wrong fields.
A malformed response is not silently ignored. One retry, because models occasionally emit a stray token and a second attempt usually succeeds. Then the item escalates, because an unparseable response means the brain has no assessment, and proceeding without one would mean guessing.
parse_failure goes to both destinations
The email still needs a human today, so the item escalates to the review app. And repeated parse failures signal a prompt or model regression, so an alert also fires to the Console. It is the one sub-reason that is both a client-facing escalation and a system signal.
1.2.2 Resolve¶
Who and what this is about. Only runs if the facts suggest existing work.
flowchart LR
Q{"Facts suggest<br/>existing work?"} -->|"no"| S["Skip"]
Q -->|"yes"| C{"Customer known?"}
C -->|"no"| COLD["Treat as cold"]
C -->|"yes"| J{"How many<br/>open jobs?"}
J -->|"one"| ONE["Link, provisional"]
J -->|"several"| AMB["Ambiguous"]
J -->|"none"| NEW["Probably a new request"]
S --> NEXT(["Continue to<br/><b>1.2.3 Decide</b>"])
COLD --> NEXT
ONE --> NEXT
AMB --> NEXT
NEW --> NEXT
Every path continues. Resolution never stops the flow: it produces context, and Decide chooses what to do with whatever context it got, including none.
Customer resolves on the sender address, then on the sender domain. The domain fallback matters, because the person emailing is often not the contact you hold: a new office manager, or someone's assistant.
| Resolution result | What follows |
|---|---|
| Customer and one job | Full context. The draft can reference the job |
| Customer, several open jobs | Ambiguous. Ask which, or escalate |
| Customer, no open jobs | Probably a new request, even if phrased as existing |
| No customer | Treat as inbound cold |
Partial resolution is the common case, and it is still valuable. Knowing the customer but not the job lets you escalate with context rather than blind, which is most of the benefit.
This stage fails often, and legitimately. That is why it is separate: a resolution failure must not lose the classification.
It re-runs every turn, not only when the item is created, so a reply naming a different site can re-link the item (ADR-0032 §6g).
A no-op where there is no system of record. For client-zero it is not: the portal holds our clients and projects, so it is a real lookup.
1.2.3 Decide¶
The remaining rules run here: vertical, tenant and learned. Guardrails already ran at 1.2.1.
flowchart LR
R["<b>Rules run here</b><br/>vertical · tenant · learned"] --> V{"Is the model's<br/>proposal still<br/>allowed?"}
V -->|"no"| E["Flag it<br/><i>attention_reason</i>"]
V -->|"yes"| A["Use it"]
E --> S["Persist, turn + 1"]
A --> S
S --> OUT(["<b>Return</b> to n8n"])
The model already proposed an action at 1.2.1. This stage checks whether it is still allowed, after the remaining rules have had their say.
| Rules can | Example |
|---|---|
| Set a field | Force priority: high |
| Remove an action | Forbid draft_reply |
They never choose. Removing everything except one option is as close as they get.
What gets flagged, and why¶
Everything flagged at this stage is a "fell short" reason: the system could not settle on an action.
| Outcome | Result |
|---|---|
| Proposal still allowed | That action goes back to n8n |
| A rule forbade what the model chose | Flag. No falling back to a second choice |
| Rules left nothing allowed | Flag. Rules contradict each other, and it is a rule bug |
| The action does not exist, or is not enabled for this tenant | Flag |
Two or more categories, and escalate_all |
Flag |
The attention_reason recorded for each, whether a draft is still possible, and whether it also alerts the Console, is in the action catalogue.
A forbidden proposal is flagged rather than falling back, because the second choice was not the model's judgement and nobody has checked whether it is any better. A human takes it.
"Worked correctly" reasons, being legal risk, safety and the like, never come from here. They come from guardrails back at 1.2.1, so anything flagged at Decide is a gap rather than a success.
These tiers may use category and sentiment, because the model has now produced them. Guardrails could not, which is why they ran earlier.
Multi-category is also settled here, per multi_category_handling. See when an email is two things.
Rule format, tiers, effects and client calibration: rules.
1.2.4 The response¶
{
"assessment": {
"category": "quote_request",
"priority": "high",
"confidence": 0.86,
"summary": "Switchboard upgrade at Preston warehouse, insurer-driven, caller wants a phone call"
},
"action": {
"name": "draft_reply",
"params": { "tone": "prompt_acknowledgement", "offer_callback": true },
"requires_approval": true
}
}
Three things worth noticing.
confidence is the model's own estimate, weakly calibrated, and must never gate anything on its own. It is for spotting patterns later.
requires_approval came from the catalogue, not the model. draft_reply is defined as requiring approval, so the model cannot decide otherwise about itself.
Not every turn produces an action. Sometimes the correct outcome is to update the item and stop.
1.2.5 Why versions are stamped at creation¶
Whatever is current at that moment, then frozen for the item's life. Change a rule this afternoon and this item does not see it (ADR-0006).
That is the difference between a system you can audit and one where nobody can explain why an old item behaved differently.
The work item¶
The fields, and where each is set. Full definitions in the data dictionary.
| Field | Means | Set at |
|---|---|---|
status |
Where the item sits at rest | 1.1, then updated throughout |
current_stage |
How far processing got | Every stage |
brain_calls |
Times the brain was invoked. Capped by max_brain_calls |
Each brain call |
model_calls |
Actual model invocations. The cost figure | Each model call |
config_version |
Which rules this item runs under | 1.2, then frozen |
catalogue_version |
Which actions it may draw on | 1.2, then frozen |
category |
The primary category | 1.2.1 |
secondary_categories |
Any others present | 1.2.1 |
priority |
Urgency, mostly set by rules | 1.2.3 |
customer_id |
Resolved customer, or null | 1.2.2, re-resolved each call |
job_id |
Resolved job, or null | 1.2.2, re-resolved each call |
job_link_basis |
How the link was derived | 1.2.2 |
job_link_certain |
Whether the draft should hedge | 1.2.2 |
attention_reason |
Null means routine. Anything else tells the human why this one is not | 1.2 |
attention_detail |
Free text for the human | 1.2 |
closed_at |
On close. Cleared on reopen | |
first_closed_at |
The first time it ever closed. Set once, never cleared | First close. What makes a reopened item countable once |
reopen_count |
How many times a reply arrived after closing | Each reopen |
The record after 1.1¶
n8n has landed the email and created the row. Almost everything is null, because nothing has been assessed.
| Field | Value |
|---|---|
id |
wi_01J8X… |
source |
email |
source_ref |
land_01J8X… |
status |
new |
current_stage |
captured |
brain_calls |
0 |
model_calls |
0 |
config_version |
null |
catalogue_version |
null |
category |
null |
secondary_categories |
null |
priority |
null |
customer_id |
null |
job_id |
null |
job_link_basis |
null |
job_link_certain |
null |
attention_reason |
null |
attention_detail |
null |
created_at |
2026-08-19T09:14:02Z |
closed_at |
null |
config_version being null is meaningful, not merely empty. It means the item is not ready, and nothing may act on it.
The record after 1.2¶
One brain call. Versions stamped, assessment written, resolution attempted. Only the changed fields are shown.
| Field | Value | Note |
|---|---|---|
status |
assessing |
|
current_stage |
decided |
|
brain_calls |
1 |
|
model_calls |
1 |
|
config_version |
12 |
Now frozen |
catalogue_version |
3 |
Now frozen |
category |
quote_request |
|
secondary_categories |
[] |
Only one thing asked |
priority |
high |
From a rule, not the model. The email mentions an insurer |
customer_id |
cust_00412 |
Resolved on the sender domain |
job_id |
null | They have no open jobs, which is normal for a quote request |
attention_reason |
null | Routine |
The versions are now frozen. A rule changed this afternoon will not reach this item (ADR-0006).
A different path: flagged¶
Same shape, different content. An email mentioning legal action, after 1.2:
| Field | Value |
|---|---|
category |
invoice_query |
priority |
urgent |
customer_id |
cust_00218 |
job_id |
job_04471 |
job_link_basis |
quoted_reference |
job_link_certain |
true |
attention_reason |
legal_risk |
attention_detail |
Customer refers to speaking with their solicitor |
It still gets a draft. If the human approves it, this item ends sent_by_us with attention_reason still set. The flag says why it needed care, the status says what happened, and they are separate fields for exactly this case.
Rows written elsewhere¶
| Table | Written at |
|---|---|
message |
1.1 inbound |
decision |
Every brain call |
stage_trace |
Every stage, as reached |
mask_map |
First time a value is masked, then reused for the item's life |
Next: 2. Execute.