Skip to content

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_rank is not priority. 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:

  • OI-060: no eval harness, so assist autonomy is the only defensible setting.
  • OI-064: masking policy described but not decided.
  • OI-065: inference residency undecided.
  • OI-066: provider terms to confirm.