Email triage¶
Sold as the Email Assistant, Senior. It runs on what the Email Assistant, Junior leaves in the action bucket, and drafts the replies.
The first product designed in full, running on client-zero, meaning Octopodia's own inbox. Assist autonomy, so every draft is approved (ADR-0032 §7).
Engines used: Capture, plus Progress in its most minimal form.
| Page | |
|---|---|
| The flow | Step by step, from arrival to close |
| Action catalogue | What the brain may propose |
| Rules | What constrains it, and how to calibrate with a client |
| Prompts | What the models are actually asked |
| Data dictionary | Every fact and observation, and what each means |
| Eval | How classification quality is measured |
| Reporting | What to monitor, and what to do about each number |
| Data model | Tables and fields |
Definitions¶
Four things, easily confused.
| What it is | Whose | |
|---|---|---|
| message | One email | Theirs, or ours if outbound |
| work item | One conversation we are handling, with a state and a history | Ours |
| job | The actual work, in their system | Theirs |
| n8n workflow | The procedure. Identical for every item | Ours |
flowchart LR
J[("job<br/>Preston switchboard")] --> W1["work_item<br/>quote enquiry"]
J --> W2["work_item<br/>reschedule request"]
W1 --> M1["message × 3"]
W2 --> M2["message × 2"]
A work item covers a conversation, not a message. The first email creates one, and a reply joins it rather than creating another.
One job produces many work items over its life, weeks apart, each a separate conversation. And a work item can exist with no job at all, which is what a new enquiry is before anyone has agreed to anything.
A genuinely new email about the same work creates a new work item, and that is fine. The job is what links them.
| Item per message | Item per conversation | |
|---|---|---|
| A customer reply | New item, starting cold | Joins the existing item, with history |
| Turn counter | Meaningless | Meaningful |
| "Did we already reply?" | Reconstruct it | A field on the item |
| Reporting counts | Messages | Conversations |
A work item is a case file, not a flowchart. It records what was decided, never what should happen next: the brain re-derives the next action each turn from state plus config (ADR-0032).
How an arriving email is matched to an item is a mechanic, and it lives in the flow.
What the brain produces¶
Two different outputs, kept separate (ADR-0032 §6d).
flowchart LR
E["Email"] --> D["Facts<br/><i>code: thread, sender,<br/>reference patterns</i>"]
D --> P["Prompt"] --> M["One model call"]
M --> O["Observations<br/>+ category"]
D --> R["Rules<br/><i>deterministic</i>"]
O --> R
R --> A["Action"]
O --> RPT["Reporting"]
classDef llm fill:#7c3aed,stroke:#5b21b6,color:#ffffff
class M llm
The purple node is the only place a language model is involved. Everywhere in these docs, purple marks a model call: non-deterministic and billable, where everything else behaves the same way every time.
Facts drive actions. Category describes the item.
| Facts | Category | |
|---|---|---|
| Question | What is observably true? | What is this about? |
| Nature | Many, independent | One, mutually exclusive |
| Verifiable | Yes, from the text or a lookup | It is a judgement |
| Used by | Rules, deterministically | Reporting, grouping, routing |
Because the category decides nothing on its own, a miscategorisation is survivable. That is a much better failure mode than one where a wrong label drives a wrong action.
Disagreement between the two is also a quality signal: facts saying "quotes an invoice number, mentions money owed" alongside a category of quote_request means something is wrong.
Categories¶
Eleven, coarse and stable. They are a vertical template for field services, inherited by every client in that vertical, with per-client additions on top (ADR-0005 precedence).
flowchart LR
G["Guardrails<br/><i>ours, locked</i>"] --> V["Vertical template<br/><i>field services</i><br/>the 11 categories"]
V --> T["Tenant additions<br/><i>e.g. warranty_claim</i>"]
T --> L["Learned<br/><i>later</i>"]
A client adds freely, and rarely removes. Removing a vertical category means their reporting no longer compares to anyone else's, and cross-client benchmarking is worth protecting.
Discipline on what goes in the template: a category that only makes sense for one client belongs in their additions. Otherwise the template stops being reusable, which is the whole point of it.
The eleven are validated at the twenty-email walkthrough during the Process Map. If several do not fit an inbox, the template is wrong, not the client.
| Category | References existing work | Is | Is not |
|---|---|---|---|
enquiry |
No | Wants information. No specific work in mind | A price (quote_request). Asking us to do something (job_request) |
quote_request |
No | Wants a price for described work | General information about what is involved (enquiry) |
job_request |
No | Wants work done | Wanting a price first (quote_request). Changing existing work (job_change) |
marketing |
No | Unsolicited selling to us | A supplier we actually use (admin) |
personal |
No | Not business | |
job_status |
Yes | Progress on work already agreed | Asking to change it (job_change). Wanting a document about it (document_request) |
job_change |
Yes | Reschedule, scope change, cancel | Asking when it is (job_status) |
document_request |
Yes | Wants a copy of something: certificate, invoice, report | A question about what it says (invoice_query) |
invoice_query |
Yes | Question or dispute about money owed | Wanting a copy of the invoice (document_request). Wanting a price (quote_request) |
admin |
Either | Insurance, compliance, registrations, supplier paperwork | Anything about a specific job |
unclear |
Unknown | Genuinely cannot tell what is being asked | Low confidence on a category you can still name |
The "is not" column does more work than the "is" column. Definitions say what fits, and exclusions say what to reject, which is the harder judgement when categories sit next to each other.
complaint is not a category
A complaint is about how someone feels, and the underlying ask is still
invoice_query, job_status or something else on this list.
It lives as sentiment and as an attention_reason, which is why an angry
email about a wrong invoice is invoice_query with the complaint recorded
alongside rather than replacing it.
references_existing_work is a property of the category, held on the catalogue entry, so once the category is chosen the flag is already known.
Categories are not buckets
A bucket is decided first, by the Email Assistant, Junior, and answers does this need anyone's attention? It results in a folder move.
A category answers what does the customer want?, and only for email
that landed in the action bucket. It results in an action.
Where Lite runs ahead of full triage, marketing and personal rarely
reach this classifier, because Lite already moved them. They stay in the
list as a fallback for tenants running full triage without Lite.
What a category is not. Urgency is priority. Who it is from is a fact. What to do about it is the action. Category is only what the message is about, which is what keeps the list short and stable.
unclear is load-bearing. A model forced to choose always chooses, confidently. An explicit escape hatch turns a silent misclassification into a visible one: the item is flagged unclear_category and gets an acknowledgement at most, never a substantive draft. Its rate is the single best quality signal: rising unclear means the taxonomy no longer fits the inbox.
Stability matters more than accuracy. Adding a category is safe. Renaming or merging one silently invalidates every prior report, which is why items pin a config version and why the client signs the list off before the first run (OI-008).
document_request is the commercially interesting one. High volume, low value, and almost entirely a lookup. The strongest eventual autonomy candidate, and worth counting separately from day one.
job_change is the one with a deadline. A reschedule sitting for six hours is worse than a quote sitting for a day, because a truck may already be dispatched.
How many categories¶
| Value | |
|---|---|
| Minimum | The 11 template categories |
| Recommended maximum | Around 15, meaning roughly four client additions |
| Hard test | Every category must see 10 or more items a month |
The volume test is the real rule, and the other two follow from it. A client on 40 emails a day gets roughly 800 a month, so a category at 1% is 8 items: too few to spot drift, too few to eval against, and too few to report on honestly.
Below the minimum, a client's reporting no longer compares to anyone else's in the vertical, which forfeits benchmarking you will eventually want to sell.
Above the maximum, selection accuracy drops (a longer list is a harder choice, for a model and for a person), and nobody can hold the taxonomy in their head, which makes the twenty-email walkthrough unreliable.
A category failing the volume test should be merged, or expressed as a fact instead. That second option is the useful escape valve: "emails from insurers" does not need to be a category, because sender domain plus organisation type is a fact, and rules can key on it without fragmenting the taxonomy. Facts have no volume floor.
When an email is two things¶
Common: "Is the CoC ready? Also, can you price the switchboard at our other site?"
Two different situations, and only one of them is really two things.
| Example | What it is | |
|---|---|---|
| Adjacent | "How's Preston going, and when's it done?" | One ask, phrased two ways. One category |
| Multiple | "Is the CoC ready? Also price Coburg?" | Two asks. Two categories |
Adjacent cases are a labelling problem, and only the model can resolve them, because it takes reading the sentence. Multiple categories are a policy problem, and code resolves them.
Which one is primary¶
The model reports every category present, in the order they appear. It does not judge which matters most. Code picks the primary.
flowchart LR
M["Model returns<br/>categories in the order<br/>they appear"] --> R{"category_selection"}
R -->|"first_mentioned"| A(["Take the first"])
R -->|"by_rank"| B(["Take the highest<br/>category_rank"])
A --> S["The rest become<br/>secondary_categories"]
B --> S
classDef llm fill:#7c3aed,stroke:#5b21b6,color:#ffffff
class M llm
Same separation as everywhere else: the model observes, code applies policy. That keeps the choice deterministic and adjustable without touching the prompt.
| Config | Options | Default |
|---|---|---|
category_selection |
first_mentioned, by_rank |
by_rank |
category_rank |
Ordered list, per tenant | From the vertical template |
by_rank is the default because it is conservative. The primary category is what gets flagged to a human, so the more consequential ask should win rather than whichever happened to be typed first.
Default ranking, highest first:
job_change · invoice_query · job_status · document_request
quote_request · job_request · enquiry · admin
marketing · personal
job_change leads because it has a deadline. marketing and personal rank last so a genuine request buried under pleasantries still wins. Per tenant, since a client with a different mix may rank differently.
Three or more categories all land in secondary_categories. Nothing is dropped. In practice that is pathological and escalates regardless.
Note
category_rankis notpriority. Priority is urgency on the work item. Rank only decides which of several categories is primary.
What happens next¶
One primary category is recorded, so reporting still sums to the total. The others are recorded as secondary_categories, not thrown away, so they are counted and they appear in the summary the approver reads.
You cannot tell at classification time whether one action closes both. It depends on what the lookups find, and the lookups have not run yet.
| Both categories | One reply closes both? |
|---|---|
| Two lookups, both clean | Yes. Answer both, item closes |
| Two lookups, one comes back "not ready" | No. That one now has its own duration |
| A lookup plus producing a quote | No. They resolve days apart |
A work item has one status, one job link and one lifecycle. When a quote is out and an invoice is still disputed, the item has no honest status. That is the real constraint, and it is why splitting is the eventual right answer rather than escalation: two tracks of work genuinely want two items.
Two behaviours, one config value¶
multi_category_handling, per tenant:
flowchart LR
A["1.2.1 Classify<br/><i>all categories recorded</i>"] --> M{"multi_category_handling"}
M -->|"escalate_all"| E(["escalate_item"])
M -->|"resolve_then_decide"| B["1.2.2 Resolve"]
B --> C{"Anything still<br/>outstanding?"}
C -->|"no"| D(["One draft closes both"])
C -->|"yes"| E
| Value | Behaviour | Status |
|---|---|---|
escalate_all |
Any second category escalates | v1 default, and the only one built |
resolve_then_decide |
Run the lookups, then decide | Seam exists, built when the data justifies it |
v1 escalates all, because it is one rule rather than a conditional depending on resolution completeness, and escalating with every category and its resolution result attached is genuinely useful to a human rather than a punt.
Then measure it. If multiple categories turn out to be a fifth of volume and most of it is two clean lookups, the refinement pays for itself, and by then there is real data to build it against.
Nothing is silently dropped. The failure to avoid is replying about the CoC and ignoring the quote request, which leaves the customer more annoyed than doing nothing would have. Either the draft addresses both, or a human takes it.
Watch the rate. A client whose inbox is a third multi-category usually has customers batching requests because responses are slow, and it should fall as response time improves.
Outcomes¶
status is the outcome. attention_reason is why it needed care. Two separate fields, because a flagged item can still be sent by us.
| Status | Meaning | In value reporting |
|---|---|---|
sent_by_us |
We drafted, a human approved, we sent | Handled |
closed_by_human |
The human handled it without using our draft | Depends on attention_reason |
closed_manual |
The human replied themselves before we got there | Neither success nor failure |
closed_no_action |
Nothing was needed | Handled |
attention_reason |
In value reporting |
|---|---|
| null | Routine |
| A "worked correctly" reason | Handled. The product working as intended |
| A "fell short" reason | Not handled. A measurable gap |
Every value, and whether it permits a draft, is in the action catalogue.
Three things deserve attention.
A "worked correctly" flag is a success. Routing a legal threat to a human for a decision is exactly what the product should do, and counting it as a failure would push you toward automating things that should never be automated.
The "fell short" reasons are your quality metric. Each one says which part is weak: unclear_category means the taxonomy has drifted from the inbox, ambiguous_job means resolution needs work, parse_failure means a prompt or model regression. Different fixes, so the breakdown matters more than the total.
closed_manual means too slow, not wrong. A high rate says the system is not answering before a human does, which is a different problem from being inaccurate and needs a different fix.
Masking¶
Identifiers are stripped before any model call, inside packages/llm-client so it cannot be bypassed.
Masking does not make content confidential
The provider still receives the substance of the email, minus some identifiers. Masking protects against prompts surfacing in logs, traces and support tickets, which is the likeliest exposure. It is not what carries a confidentiality claim to a client. That rests on provider terms: no training on inputs, and a retention position you can point at.
Over-masking also degrades the product. Mask "Preston warehouse" and the model can no longer judge urgency or match a site.
Open¶
Tracked in the register, not here: