Skip to content

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.