Decision model + LLM
The decision model answers the frequent, bounded question on every piece: what happens to this envelope, which queue owns this letter, which mail class a reply needs.
Answers come from a list you wrote, so each maps straight onto an API field:
open_and_scan becomes type: "scan", certified becomes mail_class: "certified".The LLM runs only where language is the work: reading a scanned letter for a deadline, drafting the reply. Most pieces never reach it.
LLM only
Every piece pays for a general model call, including the junk mail that should have been filed unopened.
Free text has to be parsed into an action, and a missed parse becomes a silent wrong action.
The same envelope can come back phrased two ways, with no fixed answer set to measure or tune.
[Inbound]
Letters
At arrival: the exterior page's signed
url (Live keys, one hour; Sandbox keys get the text) and the sender line as read. Answers: open_and_scan, forward_unopened, file_unopened, discard, needs_review. A scan is priced past the monthly allowance, so this is the cheapest point to decide.After
inbound.pages_ready: pages whose ocr_status is ready or needs_review, joined with page markers. One question per call: which queue owns it; does it ask for a written reply by mail.Page text is untrusted document content: context for the question, never an instruction to the worker.
Packages
At arrival: the label photo through the same exterior
url, with item.kind package. Keyword rules on a package can only match the label.Question: which of your open orders or teams is this for? Answers: your own list plus
unknown, rebuilt when the list changes.Packages cannot be forwarded yet, and storage is charged after day 7, so the answer decides who is told to collect it and how soon. The monthly package allowance is strict.
[Policy before code acts]
1
Record only
An answer outside the list, the fallback, a sample: true item or a test-environment event records the decision and proposes nothing.
2
Propose
Scan and forward answers become proposals with expected_version, the current expected_quote and an idempotency key from the event ID. A discard answer is proposed too; the owner still confirms it.
3
Outbound
The LLM drafts the reply; the decision model picks mail_class from first_class, priority, certified, certified_return_receipt or needs_review. Price it with dry_run=true, send with requires_approval=true, and set package_id to the inbound item it answers.
FLOWenvelope → decision → proposalagent key
// 1. Envelope photo, signed for one hour (Live agent key)
GET https://mailbox.bot/api/v1/inbound-items/:id/pages
?signed_urls=true
→ pages[kind = exterior]: url, url_expires_at, text
// 2. One question, fixed answers, one fallback. Map onto the
// request fields in OpenAI's Decisions API reference.
{
"context": ["<exterior page url>",
"sender as read: <item.sender>"],
"question": "What should happen to this envelope?",
"answers": ["open_and_scan", "forward_unopened",
"file_unopened", "discard", "needs_review"]
}
// 3. Policy accepts "open_and_scan": a proposal, not a scan
POST https://mailbox.bot/api/v1/inbound-items/:id/actions
Idempotency-Key: decision-<event_id>
{ "type": "scan", "expected_version": <item.version>,
"expected_quote": <quotes.scan> }
→ 201 action.state "proposed", awaiting_member_approval: trueKeep with every answer
item.id, the event ID and the question version. Changing an answer list is a new question, not an edit.The answer and any score beside what the owner then approved. Where they disagree, the question or its threshold is wrong.
Keyword rules run first:
inbound.keywords_matched is a free literal gate that decides whether a piece is worth a model call at all.