Skip to content

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.