2. Execute¶
n8n runs the branch for whichever action the brain chose.
One branch per catalogue action. An action with no branch cannot be executed, and a branch with no catalogue entry cannot be proposed. That symmetry is what makes the catalogue closed.
flowchart LR
S{"Switch on<br/>action.name"} -->|"draft_reply"| D["<b>2.1</b><br/>Draft, then<br/>await approval"]
S -->|"draft_acknowledgement"| DH["<b>2.2</b><br/>Acknowledge,<br/>then await approval"]
S -->|"close_item"| C(["<b>2.3</b><br/>Stop"])
S -->|"create_job"| J["<b>2.4</b><br/>Raise a job<br/><i>not built</i>"]
| Action | Branch | Approval | Section |
|---|---|---|---|
draft_reply |
Generate, store, request approval | Always | 2.1 |
draft_acknowledgement |
Acknowledge only, request approval | Configurable | 2.2 |
close_item |
End the loop | Configurable. Reviewed at onboarding, relaxed later | 2.3 |
create_job |
Raise a job in the client's system | Always | 2.4. Not built |
Approval is not fixed per action. The catalogue sets a floor that config can raise but never lower, so a client can require review on close_item while they build confidence (approval floors).
There is no escalate branch. Needing a human is a flag on the work item, not an action, so a flagged item can still carry a draft. Deciding is not the same as typing (see the action catalogue).
2.1 draft_reply¶
The only action that calls a model, and the only one with an approval gate.
flowchart TB
G["<b>Gather context</b><br/>job details · last 2 messages in full<br/>· approved examples · voice config"] --> M["Mask"]
M --> A["Draft prompt<br/><i>better model</i>"]
A --> U["Unmask"]
U --> R["<b>Render to HTML</b><br/>tenant template + signature"]
R --> W["Write the draft"]
W --> H["Write the approval record<br/>+ hash of the rendered body"]
H --> S(["status: awaiting_approval<br/>n8n run ends"])
classDef llm fill:#7c3aed,stroke:#5b21b6,color:#ffffff
class A llm
This is a second brain call, separate from Classify, and a second model call. It happens here rather than inside Classify because rules have now confirmed draft_reply is permitted, so no draft is ever paid for and then forbidden.
What it gathers¶
Classify left pointers, not content. Draft fetches the detail.
| Needs | Where from | Note |
|---|---|---|
| Job details: status, scheduled date, site | Tenant Postgres, by job_id |
Already resolved. No re-resolution |
| Last two messages, in full | message rows |
Classify used one-line summaries to keep cost linear. Drafting needs the wording, because "we said someone would call Tuesday" must be quoted accurately |
| Approved examples | Remember, via pgvector | Past approved drafts for this tenant, similar to this situation. This is what makes it sound like them |
| Voice and tone | Tenant config | Sign-off, formality, standing instructions |
| Customer name and how they are addressed | customer row |
Empty example set is expected, not a bug. Client-zero has no approved drafts on day one, so retrieval returns nothing and Draft falls back to the tenant's voice config. Slightly more generic prose, corrected by the approver, and those corrections seed the store. Worth knowing so nobody spends a day debugging why retrieval is empty.
Who does what¶
"Code" is two different components, split by what each can reach.
| Component | Why | |
|---|---|---|
| Body text | Brain | The model call |
| HTML render, signature | Brain | The template is tenant config in the registry, and only the brain reads the registry |
| Subject, recipients, threading headers | n8n, via the mail connector | It holds the provider connection |
| Writing and saving the draft | n8n | Same |
n8n does not compute recipients itself. It calls the mail connector's create_reply_draft, which guarantees correct threading and addressing. Reimplementing reply-all semantics is a reliable way to get cc handling subtly wrong.
So: the brain returns rendered HTML, the connector creates a correctly threaded draft, n8n sets the body and saves it.
The mail connector¶
Provider-agnostic, the same way llm-client is model-agnostic. Built per source, not per client.
| Operation | Must guarantee |
|---|---|
watch |
New messages delivered as they arrive |
fetch_message |
Raw message with RFC 5322 headers intact |
create_reply_draft |
A draft correctly threaded and addressed |
send_draft |
Returns the sent Message-ID |
list_thread_outbound |
Outbound messages on a thread, for reconciliation |
create_reply_draft is where providers genuinely differ, which is why it is an interface rather than a thin wrapper:
| Provider | How it threads and addresses |
|---|---|
| Microsoft Graph | createReply populates recipients, subject and headers |
| Gmail API | Construct the MIME, set In-Reply-To and References, pass threadId |
| IMAP and SMTP | Entirely manual |
The interface promises the outcome. Each implementation earns it differently.
What the model returns, and what it does not¶
| Set by | |
|---|---|
| Body | The model, as plain text or light markdown |
| Subject | n8n, via the connector's reply operation. Never changed |
| Recipients | n8n, via the connector's reply operation |
| HTML, signature | The brain, from the tenant's template |
The model never sets recipients
Adding a cc, or replying to a different address, is closer to an action
than to drafting. A model that can add recipients can send client information
to someone who was never on the thread, and no amount of prompt instruction
makes that safe.
If a client needs "always cc accounts on invoice queries", that is a rule: deterministic and auditable, and applied by n8n rather than proposed by the model.
The subject is never changed. There is no reason to, and changing it breaks threading in some clients and looks odd in others.
The model does not author HTML. It returns text, and code renders it into the tenant's template. That avoids broken markup, keeps branding consistent, and means the signature is never something the model has to remember. The content hash is over the rendered output, since that is what would be sent.
Masking, and why tokens¶
The Draft call has its own inputs, so it needs its own masking. Not a re-mask of the same text.
Identifiers are replaced with tokens ([PERSON_1]), never with plausible fake names. The difference is how failure behaves:
| If replacement fails | Customer sees | Caught? |
|---|---|---|
| Token | Dear [PERSON_1] |
Yes. Obviously broken |
| Pseudonym | Dear Robert |
No. Reads as a normal email |
A human approving forty drafts a day skims. Tokens fail loudly, pseudonyms fail silently, and with an approval gate in front, loud failure is what you want.
The cost is that models write slightly more stiffly around bracketed tokens. Worth less than the silent-failure risk.
Where the draft goes¶
Depends on the tenant's approval surface (ADR-0032 §6h):
review_app |
mailbox |
|
|---|---|---|
| Draft written to | Tenant database | The client's mailbox, unsent |
| Approved in | The review app | Their mail client |
| Who sends | Us, via the mail connector | The human |
One mode per tenant, never both
Two live approval surfaces on one work item means two places a human could act, so reconciliation would have to guard against our own other surface as well as the human, and the evidence chain splits: the same item could carry an approved-and-hashed record and an unverifiable manual send.
The config value has no "both" option. Changing a tenant's mode applies to new work items only (ADR-0006), because in-flight items had their approval records written under the old mode's rules.
The approval record¶
Written either way: work item ID, action name, exact parameters, and a content hash of the unmasked draft body, because that is what would be sent.
The hash exists so "the client approved this exact text" is provable (OI-018). In review_app mode editing happens in the app and the text is re-hashed. In mailbox mode the human can edit invisibly, which is why that mode proves only what we proposed.
Then the n8n run ends. Nothing polls, nothing waits.
If drafting fails¶
The model will always produce prose, so this is never a content failure. The real failures are infrastructure: rate limit, timeout, provider outage.
flowchart LR
D["Draft call"] -->|"transient error,<br/>retry"| D
D --> F{"Still failing after<br/>3 attempts?"}
F -->|"no"| OK(["Continue"])
F -->|"yes"| SYS(["<b>System failure</b><br/>Alert the Console.<br/>Item stays unprocessed"])
Three attempts total, being the first plus two retries, with exponential backoff of roughly one second then four.
| Response | Retry? |
|---|---|
| Timeout, connection reset, 5xx | Yes. Transient |
| 429 rate limited | Yes, and honour the Retry-After header rather than guessing |
| 400 bad request | No. Retrying an invalid request just fails again more expensively |
| 401 or 403 | No. A credential problem, and it will not fix itself |
The limit is a system setting rather than tenant config, since it is about provider behaviour rather than anything a client would have a view on.
This does not escalate to the client. Nothing is wrong with the email, and a human being asked to look at it would find nothing to decide. It is our outage, so it goes to the Console and the item waits.
That is the general rule: where a problem goes.
2.2 draft_acknowledgement¶
An acknowledgement, not an answer. "Thanks, we have your message, someone will come back to you today."
flowchart LR
T["Acknowledgement template<br/><i>tenant config</i>"] --> R["Render to HTML"]
R --> W["Write the draft"]
W --> H["Write the approval record"]
H --> S(["status: awaiting_approval"])
No model call. It is a template render, so model_calls does not increment.
It is derived, not chosen. The model always proposes draft_reply, and code downgrades here once attention_reason is known. Full table in the action catalogue.
Off by default, per acknowledgement_enabled. When false, a downgrade becomes no draft, and the human gets the item with nothing.
| Model call | None |
| Approval | Configurable. Some clients will want it automatic |
| Queue priority | Low, regardless of the item's own priority |
| Alongside a substantive draft | Never. One or the other |
It needs no understanding of the request, which is exactly why it is safe when the brain could not work out what was being asked. It buys the human time to deal with the item properly.
2.3 close_item¶
Ends the loop. Nothing is sent, nothing is written to the customer.
| Situation | Status |
|---|---|
| A reply was sent and nothing further is needed | sent_by_us |
| Nothing was ever needed: marketing, an automated notification, a thank-you | closed_no_action |
2.3.1 Review is configurable¶
close_item is in the catalogue as not requiring approval, but that is a floor, not a fixed value. close_review decides what actually happens.
| Mode | Behaviour | When |
|---|---|---|
required |
Nothing closes until a human confirms | Onboarding. Nobody yet trusts the classification |
notify |
Closes immediately, appears in a "closed today" list | Once confidence is building |
none |
Closes silently, visible in history | Steady state |
flowchart LR
O["Onboarding:<br/><b>required</b>"] --> C["Confidence<br/>builds"]
C --> N["<b>notify</b>"]
N --> L(["<b>none</b>"])
The middle rung matters. Going from required straight to none loses sight of closures entirely, which is a bigger jump than most clients want. notify costs nothing to build, since closed items are already visible in history.
close_item is the first action worth relaxing, because a wrongly closed item is recoverable and visible, whereas a wrongly sent email is not.
draft_reply and create_job can never be relaxed. Both change something outside our control, and the catalogue floor forbids it (approval floors).
2.3.2 What the human sees under required¶
There is no draft to read, so the review is a confirmation rather than an approval.
| Shown | |
|---|---|
| The email | |
| The category | Which is the thing most likely to be wrong |
| Why nothing is needed | |
| Confirm, or correct |
The correction is the valuable part. If the human says "no, this needed a reply", that is a category correction and a labelled example in one, which is exactly what the eval set needs.
So an early-stage close review is a labelling exercise as much as a safety gate, which is a better reason to run it than caution alone.
2.3.3 Closed items stay visible¶
They remain in the review app's history and in reporting, so a client can see what was handled and what was dismissed. An item that vanishes silently is one nobody can audit.
closed_no_action is the outcome most worth sampling: closing something that needed a reply is the worst failure this product has. It is also the argument for reviewing it during onboarding, since that is exactly when nobody yet knows whether the judgement is sound.
A reply arriving on a closed item reopens it rather than creating a new one, since the customer is continuing the same conversation.
2.4 create_job¶
Raises a job in the client's system. Requires approval, always.
Not built. Client-zero has no field-service system, so this arrives with the first client that does.
The brain proposes, n8n creates¶
The brain holds no credentials to any client system (ADR-0032 §2). It extracts fields and proposes; n8n calls the client's API through the connector.
Gaps are filled at review, not blocked¶
An earlier version of this design had two modes: a full job when every mandatory field was present, and a placeholder stub when they were not. That split turned out to be unnecessary.
A placeholder is just a job with fields missing. So there is one flow: propose with what the email gave, mark what is absent, and the human completes it when approving.
flowchart LR
E["Extract what<br/>the email gives"] --> C{"All mandatory<br/>fields present?"}
C -->|"yes"| F["Propose a<br/>complete job"]
C -->|"no"| P["Propose it with<br/>gaps marked"]
F --> A(["Human approves"])
P --> A2(["Human fills gaps,<br/>then approves"])
The review screen is a form prefilled with what we know:
| Field | |
|---|---|
| Customer | Resolved. cust_00412 |
| Description | From the email |
| Site address | Missing. The human types it |
| Job type | Missing. Dropdown from their system |
That is a better product than "we could not do this, over to you". The human is finishing something rather than starting it.
What this needs¶
A field map per system, with allowed values. Not just names: a job-type dropdown means reading their taxonomy, so the map is a live schema read rather than static config.
final_payload_hash on the approval record. The human changed what we proposed, so the hash must be over what was actually submitted, exactly as with an edited draft.
A per-tenant on or off switch. Some clients raise jobs themselves and always will.
Whether this is worth building depends on their field count
A prefilled form with two gaps beats opening their system and typing. A form with fourteen fields is a worse version of their own interface.
That cannot be settled on paper. It is an OI-001 question, answered by the AroFlo spike.
Reconcile first¶
Someone may have raised the job by hand while the item sat waiting, exactly as with sending (ADR-0032 §6g).
The work item after step 2¶
Only four fields moved. The assessment is unchanged, because nothing new was learned.
| Field | Value | Note |
|---|---|---|
status |
awaiting_approval |
|
current_stage |
awaiting_human |
|
brain_calls |
2 |
The draft was a second call |
model_calls |
2 |
Everything else keeps what it held after 1.2. New rows land in approval and, for draft_reply, in the draft store.
Next: 3. Approve and loop.