# KEEL — Architecture & Technical Reference

**Status:** current as of 22 Aug 2026 · verified against the code in `app/`, `lib/`, `components/`
**Scope:** how the system is built, how a cycle executes, what each module owns, and where the
boundaries are. For *who uses it and why*, see [USER-JOURNEY.md](./USER-JOURNEY.md). For the
narrative walkthrough of each algorithm, see [GUIDE.md](./GUIDE.md).

---

## 0. One paragraph

KEEL is a single Next.js 16 application that runs an autonomous operations controller for a
manufacturing plant. It perceives a procurement world (inventory, purchase orders, suppliers,
production schedule, email), detects when production is threatened, investigates using tools,
challenges supplier claims against independent evidence, computes recovery plans with deterministic
solvers, executes what it is authorised to execute, escalates the rest with a decision brief, and
writes an audit trail for every step. **Exactly one step of its loop is an LLM call.** Everything
that produces a number is plain TypeScript.

---

## 1. System map

```
┌──────────────────────────────────────────────────────────────────────────────────────┐
│  BROWSER                                                                             │
│                                                                                      │
│  Operator app (AppShell + Sidebar)              Supplier portal (no nav)             │
│  /                    Mission Control           /supplier/[token]?run=<id>           │
│  /run/[id]            Agent Console  ★          — a person answers AS the supplier   │
│  /run/[id]/approvals  Approval inbox                                                 │
│  /run/[id]/mail       Mail client                                                    │
│  /run/[id]/suppliers  Supplier list + portal links                                   │
│  /run/[id]/erp        ERP records + spreadsheet connect                              │
│  /run/[id]/impact     Counterfactual — what it prevented                             │
│  /run/[id]/audit      Decision trail + rubric score                                  │
│  /integrations        What is actually wired up                                      │
│  /lab                 Engine Lab — solvers with no agent attached                    │
└───────────────┬──────────────────────────────────────────────────────────────────────┘
                │  fetch() polling: 1–5 s per screen. No sockets, no SSE.
┌───────────────▼──────────────────────────────────────────────────────────────────────┐
│  NEXT.JS ROUTE HANDLERS  (app/api/**)                                                │
│                                                                                      │
│  run          POST /api/run            seed a run          GET  list runs+scenarios  │
│               GET  /api/run/[id]       redacted state + scorecard + counterfactual   │
│  agent        POST /api/agent/tick     ONE cycle (the loop)                          │
│               POST /api/agent/perceive perceive only, no LLM                         │
│  human        POST /api/approvals      approve / reject an escalation                │
│               POST /api/supplier       human takeover reply    GET thread            │
│               POST /api/inject         break the world mid-run                       │
│  io           POST /api/erp/import     upload .xlsx as the ERP                       │
│               GET  /api/erp/export     download live state as .xlsx                  │
│               GET  /api/erp/template   blank workbook with the right columns         │
│               POST /api/sheet/connect  attach a live workbook (path or URL)          │
│               POST /api/sheet/sync     re-read it now                                │
│               POST /api/email/inbound  webhook for a real supplier reply             │
│  status       GET  /api/integrations   LLM / email / sheet / store connectivity      │
└───────────────┬──────────────────────────────────────────────────────────────────────┘
                │
┌───────────────▼──────────────────────────────────────────────────────────────────────┐
│  DOMAIN LAYER  (lib/)                                                                │
│                                                                                      │
│  lib/agent/     loop.ts  register.ts  tools.ts  prompts.ts  llm.ts                   │
│                 scorecard.ts  counterfactual.ts  clock.ts                            │
│  lib/solve/     coverage.ts  claims.ts  recovery.ts  costOfDelay.ts  time.ts  rng.ts │
│                 ── deterministic. No LLM. Unit-tested. Seeded RNG.                   │
│  lib/sim/       seed.ts  personas.ts  mail.ts  injections.ts                         │
│                 ── the world the agent lives in, including hidden ground truth       │
│  lib/io/        spreadsheet.ts  sheetSync.ts                                         │
│  lib/store/     index.ts — one RunState document per run                             │
└───────────────┬───────────────────────────────┬──────────────────────────────────────┘
                │                               │
     ┌──────────▼───────────┐        ┌──────────▼────────────────────────────────┐
     │ STORE                │        │ EXTERNAL (all optional)                   │
     │ dev  in-memory Map   │        │ LLM      OpenAI-shaped chat completions   │
     │      on globalThis   │        │ Email    Resend (off unless enabled)      │
     │ prod Upstash Redis   │        │ Sheet    local .xlsx path or any URL      │
     │      REST, 12 h TTL  │        │ Store    Upstash Redis REST               │
     └──────────────────────┘        └───────────────────────────────────────────┘
```

**One deployable.** UI, API and agent are the same Next.js app. There is no worker, no queue, no
database migration, no separate agent service.

---

## 2. The cycle — what one tick actually does

One cycle = one `POST /api/agent/tick` = one click of **Step** (or one auto-tick every ~700 ms).
Implemented in `lib/agent/loop.ts:tick()`.

```
  ┌─ 1. PERCEIVE ──────────────────────────────────────── no LLM ─┐
  │  a. re-read the connected spreadsheet, diff it semantically     │  lib/io/sheetSync.ts
  │  b. deliver POs whose date has passed AND that physically moved │  register.ts
  │  c. parse newly visible inbound mail; vagueness costs trust     │  solve/claims.ts
  │  d. recompute coverage for EVERY component                      │  solve/coverage.ts
  │  e. open / update / resolve rows in the risk register           │
  │  f. expire assumptions past their TTL → reopen those risks      │
  └────────────────────────────────┬────────────────────────────────┘
  ┌─ 2. SELECT ────────────────────▼──────────────────── no LLM ─┐
  │  mostUrgent(): severity, then soonest stockout.                │  register.ts
  │  A risk blocked on a pending approval is DEPRIORITISED, not    │
  │  waited on — otherwise the tool budget burns re-escalating.     │
  │  No open risks → status = done. Budget exhausted → stop.        │
  └────────────────────────────────┬────────────────────────────────┘
  ╔═ 3. REASON ════════════════════▼═══════════════════ ★ LLM ★ ═╗
  ║  callPlanner(SYSTEM, buildUserPrompt(run, risk, last5), 15 tools)
  ║  tool_choice: "required"  → returns { reasoning, tool, args }   ║  agent/llm.ts
  ║                                                                 ║  agent/prompts.ts
  ║  IN PARALLEL (Promise.all): any supplier reply owed from the    ║
  ║  previous cycle is generated now, so a tick never pays for two  ║  sim/personas.ts
  ║  serial round trips to the model.                               ║
  ╚════════════════════════════════┬════════════════════════════════╝
  ┌─ 4. GOVERN ───────────────────▼─────────────── LLM, on repeat ─┐
  │  If the chosen call is a READ that was already made with the    │  loop.ts
  │  identical arguments, REFUSE it — and hand the refusal plus the │
  │  earlier result straight back to the planner, which chooses     │
  │  again. Up to 2 retries.                                        │
  │                                                                 │
  │  Blocking used to end the cycle. That was a trap: the next      │
  │  cycle presented identical state, the model made the identical  │
  │  choice, and a run burned 15 cycles calling one tool 14 times.  │
  │  A refusal is only useful if the model hears it.                │
  └────────────────────────────────┬────────────────────────────────┘
  ┌─ 5. ACT ───────────────────────▼──────────────────── no LLM ─┐
  │  executeTool() mutates the world.                              │  agent/tools.ts
  │  toolCallsUsed += 1                                            │
  │  simClock += CLOCK_COST[tool]  (reading 5 min, RFQ 30 min,     │
  │                                 solvers 0 — thinking is free)  │
  └────────────────────────────────┬────────────────────────────────┘
  ┌─ 6. RECORD ────────────────────▼──────────────────── no LLM ─┐
  │  audit.push({ cycle, simClock, riskId, kind, headline,         │
  │               reasoning, tool, args, result })                 │
  │  plus a second entry for any side effect worth its own line    │
  │  (contradiction / discrepancy / escalation / decision)         │
  └────────────────────────────────┬────────────────────────────────┘
  ┌─ 7. QUEUE ─────────────────────▼─────────────────────────────┐
  │  if the tool was send_supplier_message and the supplier is not  │
  │  human-controlled → push to run.pendingReplies. Written at the  │
  │  top of the NEXT cycle, alongside the planner call.            │
  └────────────────────────────────┬────────────────────────────────┘
                                   └──▶ store.set(run) → back to 1
```

### Why one cycle per request

| Reason | Consequence |
|---|---|
| Serverless functions time out | `maxDuration = 60` covers one LLM call + one tool comfortably |
| A run must survive a refresh | State lives in the store, not in a process |
| A demo must be watchable | The console shows the agent thinking **one step at a time**, not a wall of text |
| Cost is bounded | Hard `toolCallBudget` (60) and a repeat-call governor |

### Cost per cycle

| | Calls | Notes |
|---|---|---|
| Planner | 1, up to 3 | +1 firmer retry if the model narrates instead of acting; +up to 2 more if it picks a repeat read and has to choose again |
| Supplier persona | 0 or 1 | only when a reply is owed; runs in parallel, off the critical path |
| Deterministic solvers | ∞ | free, local, ~ms |

A tool call is only counted against `toolCallBudget` when it actually executes, so the governor's
retries cost model tokens but not budget.

---

## 3. Where the LLM sits, and what it may not do

```
   lib/agent/llm.ts — one adapter, OpenAI-shaped chat completions

   LLM_PROVIDER = azure-ai (default) │ openai │ <anything with a base URL>
   LLM_MODEL    = Kimi-K2.5 (default)
   LLM_ENDPOINT = https://…            LLM_API_VERSION = 2024-05-01-preview
   LLM_API_KEY  = …

   azure-ai →  {ENDPOINT}/models/chat/completions?api-version=…   header: api-key
   openai   →  https://api.openai.com/v1/chat/completions          header: Authorization
   other    →  {ENDPOINT}/chat/completions                         header: Authorization
```

Hardening that is in the code because it was needed in practice:

- **`tool_choice: "required"`** — left free, a reasoning model narrates instead of acting, and a
  cycle with no action is a wasted cycle against a hard budget.
- **No-tool retry** — if the model still emits no call, it is asked once more, firmly, with a
  smaller token cap. One retry is far cheaper than a lost cycle.
- **`reasoning_content` fallback** — some reasoning models leave `content` empty when they go
  straight to a tool call. Visible text is preferred, thinking is the fallback.
- **`condense()`** — takes the substantive tail of a long first-person draft, capped at 420 chars,
  so the console reads like an operator's note.
- **Retry with backoff** — 3 attempts on 429/5xx/network, 90 s timeout.

### What the planner is shown each turn

`buildUserPrompt()` rebuilds the whole working picture every cycle, because tool results scroll out
of the recent-actions window and an agent that cannot see what it already knows will fetch it again:

| Block | Why it is restated |
|---|---|
| Clock, cycle, budget spent, tool calls used | the constraints it is optimising against |
| The risk in focus — coverage, stockout, exposure, open questions | the task |
| Other open risks | so it knows the queue exists |
| Awaiting-human list | *do not block on these, work another risk* |
| **Decisions from your manager** | an approval is an instruction to execute; a rejection is an instruction that the plan is off the table and must not be re-escalated |
| Supplier memory | reliability, contradictions, vague replies on record |
| **Open orders for this component** | it called `get_purchase_orders` fourteen times with identical arguments before this was added |
| **Recent supplier mail**, each tagged FIRM / NOT A COMMITMENT | stops it re-reading the inbox |
| **Plans already costed** | otherwise it recomputes forever instead of choosing |
| A **decision point** line | "every plan needs approval — escalate the best one NOW", or "PLAN-002 is within your authority — execute it" |
| Its own last 5 tool calls and results | continuity |

**The rule the system prompt enforces:** *"You do NOT perform arithmetic. Call `compute_coverage`,
`solve_recovery` and `cost_of_delay` for every number you use. Never state a figure you did not get
from a tool."*

> The LLM decides **what to do next**. TypeScript decides **what the numbers are**.

---

## 4. Tool catalogue

15 tools, three classes. Schemas in `lib/agent/tools.ts:TOOL_SCHEMAS`, dispatch in `executeTool()`.

### Read — costs simulated minutes, returns redacted data

| Tool | Clock | Returns |
|---|---|---|
| `get_inventory` | 5 min | ERP rows **minus `_physicalStock`** |
| `get_purchase_orders` | 5 min | POs incl. the `trusted` flag |
| `get_suppliers` | 5 min | catalogue **minus `_persona`, `_trueLeadTimeDays`, `portalToken`**, plus `effectiveReliability` and `contradictionsOnRecord` from memory |
| `get_production_schedule` | 5 min | orders, deadlines, priority, revenue, reschedulability |
| `get_messages` | 5 min | only messages where `sentAt <= simClock`; inbound arrive pre-parsed |
| `get_shipment_events` | 10 min | carrier scans — **the independent evidence source** |
| `check_budget` | 5 min | whether a cost is inside the autonomous threshold |

### Solve — deterministic, free, zero clock

| Tool | Implementation |
|---|---|
| `compute_coverage` | `lib/solve/coverage.ts` |
| `solve_recovery` | `lib/solve/recovery.ts` — persists its plans onto `run.plans` so later tools can reference them by id |
| `cost_of_delay` | `lib/solve/costOfDelay.ts` |

Zero clock cost is a deliberate incentive: **thinking is free, committing costs time**, exactly as
it does in a real plant.

### Act — mutates the world

| Tool | Clock | Effect |
|---|---|---|
| `send_supplier_message` | 15 min | appends outbound mail, optionally delivers over Resend, queues the persona's reply for the next cycle |
| `request_rfq` | 30 min | a live quote (price, availability, delivery days, expedite terms, 6 h validity) |
| `erp_update` | 10 min | `mark_po_delayed` · `distrust_po` · `create_po` · `attach_note` · `set_risk_status` · `execute_plan` |
| `request_approval` | 10 min | creates an `Approval` — **rejected unless the brief is complete** |
| `reschedule_production` | 10 min | delays an order; fails if the order forbids it |

Two guards worth naming:

- `erp_update(execute_plan)` **refuses** a plan above the autonomous threshold unless a matching
  approval exists with `decision === "approved"`.
- `request_approval` **refuses** if any brief field is missing, and refuses a second open request
  for a risk a human is already looking at. An escalation must be a decision, not an alert.

### Side effect worth its own audit line

`get_shipment_events` auto-verifies the most recent outstanding dispatch claim for that PO. On a
contradiction it: records it in supplier memory, multiplies `effectiveReliability` by 0.6, sets
`po.trusted = false` (which removes the PO from coverage arithmetic), and emits a `contradiction`
audit entry that states explicitly that **alternate sourcing continues**.

---

## 5. Data model

`lib/types.ts`. One `RunState` document holds everything.

```
RunState
├─ id, scenario, seed, simClock, startedAt, status, cycle
├─ toolCallsUsed / toolCallBudget            (60)
├─ WORLD
│   ├─ components[]        Component      currentStock │ usableStock │ _physicalStock ⚑
│   ├─ suppliers[]         Supplier       _persona ⚑ │ _trueLeadTimeDays ⚑ │ portalToken
│   ├─ purchaseOrders[]    PurchaseOrder  trusted: boolean  ← the coverage gate
│   ├─ shipmentEvents[]    ShipmentEvent  label_created → picked_up → … → delivered
│   ├─ productionOrders[]  ProductionOrder
│   ├─ budget              emergencyTotal ₹5,00,000 │ autonomousThreshold ₹1,50,000
│   └─ messages[]          Message + ParsedCommitment + MaterialClaim[]
├─ AGENT MEMORY
│   ├─ risks[]             Risk — the working memory (see §7)
│   ├─ plans[]             Plan — costed options, referenced by id
│   ├─ approvals[]         Approval + ApprovalBrief
│   ├─ supplierMemory{}    promisesMade/Kept, vagueResponses, contradictions[], effectiveReliability
│   └─ audit[]             AuditEntry — 9 kinds, append-only
├─ baseline{}              the t=0 snapshot the counterfactual compares against
├─ dataSource?             seed │ spreadsheet
├─ sheet?                  live workbook connection
└─ pendingReplies[] · firedInjections[] · pendingInjections[]
```

**⚑ = ground truth.** Fields prefixed `_` exist so the simulation can lie to the agent the way a
real ERP and a real supplier do. They are stripped in three places, independently:

1. `get_inventory` / `get_suppliers` destructure them out of every tool result.
2. `GET /api/run/[id]` strips them before serialising to the browser — a judge reading the network
   tab finds nothing the agent could not see.
3. They are never included in any prompt except the supplier persona's own system prompt, which is
   the one actor entitled to know them.

---

## 6. Persistence

`lib/store/index.ts` — a three-method interface (`get` / `set` / `list`) with two implementations.

| | Dev | Deployed |
|---|---|---|
| Backing | `Map` pinned to `globalThis` | Upstash Redis over REST |
| Trigger | default | `KV_REST_API_URL` + `KV_REST_API_TOKEN` present |
| Keys | — | `keel:run:<id>` (12 h TTL), `keel:runs` sorted set |

Pinning the Map to `globalThis` matters: Next.js hot-reloads modules on every edit, and a plain
module-level Map would take every in-flight run with it.

**Why no relational DB:** tens of records per run, read-modify-write once per tick, one writer.
A schema plus migrations would cost hours and buy nothing at this size.

---

## 7. The risk register — why there is no workflow

`lib/agent/register.ts`. The register is the agent's working memory, and it is the reason the
system is an agent rather than a pipeline.

```
Risk {
  id, componentId, trigger, detectedAt, severity, status
  coverageDays · shortfallUnits · firstStockoutAt · affectedOrders[]
  assumptions[]      ← each with source, confidence, verifiedAt, expiresAfterHours
  rejectedOptions[]  ← what was turned down and why
  planId · openQuestions[]
}

status: open → investigating → planned → executing → resolved
                    ↑                                    │
                    └──────── reopened ──────────────────┘
                       (an assumption expired, or a human rejected the plan)
```

- **Multiple simultaneous disruptions** are just multiple rows. Nothing had to be designed for it.
- **Replanning is not a special case.** `invalidateExpiredAssumptions()` walks every assumption; one
  past its TTL sets `verifiedAt = null`, drops confidence to 0.2 and moves the risk back to `open`.
  *A plan is a set of assumptions with expiry dates.*
- **Severity** comes from `severityFromCoverage()`: high-priority exposure and <2 days cover is
  critical; <3 days or any high-priority exposure is high; <7 days is medium.
- **Selection** deprioritises a risk blocked on a pending human decision, so the agent works
  something else instead of burning budget re-escalating.

---

## 8. The deterministic solvers

All four are pure functions with no I/O, unit-testable, and exposed both to the agent (as tools)
and to a human (in `/lab`).

### 8.1 `computeCoverage` — never trust one stock figure

```
signals = [ ERP currentStock , warehouse usableStock , usableStock − dailyUsage × hoursStale ]
stockBasis        = MIN(signals)              ← the most pessimistic
discrepancy       = MAX − MIN                 ← >0 means the ERP is wrong, and by how much
availableForProd  = stockBasis − safetyStock

then walk 30 days forward, day by day:
   add trusted inbound arriving that day  ·  subtract dailyUsage
   first day the running total goes negative → firstStockoutAt (interpolated within the day)
```

Untrusted POs are **excluded** from inbound and reported separately as `excludedInbound`. That one
boolean is how a caught lie changes the arithmetic.

Two distinct ways a production order fails, and MRP systems routinely catch only the first:

| | Failure | Caught by |
|---|---|---|
| (a) | **Quantity** — not enough units arrive by the deadline | every system |
| (b) | **Timing** — enough arrive eventually, but the line runs dry first | this one. A 1,000-unit delivery on the 4th does not save a line that stops on the 3rd |

### 8.2 `parseCommitment` / `verifyClaim` — language as evidence

```
isFirm  =  a specific date  AND  a specific quantity  AND  zero hedges
hedges  =  22-term lexicon + "5-7 days"-style ranges
claims  =  dispatched │ in_stock │ quantity_available │ eta
```

Tense discrimination is deliberate: *"we have dispatched"* is an assertion to verify now;
*"we will dispatch Thursday"* is a promise to check later. The `DISPATCHED` pattern has no trailing
word boundary so past tense forms are caught; an earlier version silently missed every one of them.

`verifyClaim` compares a dispatch claim against `shipmentEvents`:

| Evidence | Verdict |
|---|---|
| no events at all | **contradicted** — a dispatch claim with no carrier record is not supply |
| `label_created` only | **contradicted** — a label was created but no pickup occurred |
| any of picked_up / in_transit / out_for_delivery / delivered | verified |

A vague reply is not free either: in `perceive`, a non-firm reply increments `vagueResponses` and
sets the backing PO to `trusted = false`, which removes it from coverage until specifics arrive.

### 8.3 `solveRecovery` — allocation, not selection

```
1  CONSTRAINT GATE  (before any cost is looked at)
   missing certification → rejected, with the reason recorded
   qualityScore < minQualityScore → rejected
   availableQuantity < own MOQ → rejected
   Every rejection keeps wouldHaveCost and wouldHaveArrivedDay, so the audit trail can show
   that the rejected option was cheaper AND faster — and was still correctly rejected.

2  DEMAND vs SUPPLY curves over max(lastDeadline+3, 10) days
   Demand comes from production orders, not a flat rate — that is what rescheduling moves.

3  ENUMERATE
   changeSets = [ no reschedule ] + [ delay each low-priority reschedulable order by 2 days ]
   for each changeSet:
     if rescheduling alone closes the gap → a ZERO-COST plan (always surfaced)
     for k = 1..3 suppliers:
       for every combination × every expedite on/off mask:
         greedyFill(soonest, then cheapest) honouring MOQ and availability
   A single-supplier plan is just k=1. Splitting is not hardcoded — it falls out of the search.

4  SCORE each candidate
   continuityProbability = 500-run Monte Carlo on seeded mulberry32:
       each allocation independently slips 3 days with probability (1 − reliability)
       plan survives iff supply ≥ demand on EVERY day
   score = 0.60·continuity − 0.30·(cost / maxCost) − 0.10·supplierConcentration
   dedupe identical allocation shapes → return the top 3, each with full workings[]
```

Continuity probability is what lets the agent say *"Plan B is ₹8,000 cheaper but drops continuity
from 0.91 to 0.62"* — a cost number alone cannot express that.

### 8.4 `costOfDelay` — price speed against what it avoids

```
costOfSpeed  = Σ expedite fees + Σ (unitPrice − baselinePrice) × qty
costOfDelay  = Σ slipDays × 24 × downtimeCostPerHour  +  revenueAtRisk × (1 − continuity)
recommend    = costOfDelay > costOfSpeed AND costOfSpeed > 0
```

Both numbers are always returned, so the agent can decline to expedite **and show the arithmetic**.

---

## 9. The simulation layer

Everything the agent perceives comes from `lib/sim/`. It is a sandbox, and it is adversarial by
construction.

### 9.1 Seeding — `seed.ts`

Seven scenarios, each shaping a different trap; all anchored to sim start `2026-09-01T09:00`.

| | Scenario | The trap | Hidden tests |
|---|---|---|---|
| S1 | Baseline delay | SUP-21 delays PO-7712 by 5–7 days | — |
| S2 | Phantom inventory | ERP shows 800; only 390 usable | #2 |
| S3 | Adversarial claim | Supplier claims dispatch; only a label exists | #8 |
| S4 | Quality trap | Cheapest alternate lacks Automotive-Grade | #3 |
| S5 | Approval wall | The only feasible plan exceeds the threshold | #9 |
| S6 | Twelve-hour cliff | A high-priority line stops within 12 h | #4, #5 |
| **S7** | **Compound (Layer 3)** | Phantom stock + a lying supplier + a mid-run priority flip | #1, #2, #8, #10 |

The seed drives supplier names, quantities, thresholds and injection timing through
`mulberry32(seed)`. **Running a fresh seed live is the only convincing answer to "is this
hardcoded?"** — COMP-104 stays constant only because it is the identifier the problem statement
uses.

S7 additionally schedules `priority_flip` at cycle 12 and `demand_spike` at cycle 18.

### 9.2 Suppliers are LLM agents with hidden personas — `personas.ts`

Not scripted replies. Each supplier gets a system prompt containing its persona brief and the
ground truth it knows but must not state.

| Persona | Behaviour | Reply latency |
|---|---|---|
| `honest` | accurate dates and quantities, problems stated plainly | 1 h |
| `optimistic` | believes the best case and states it as fact — *the Philips persona: the real answer was nine months and it would have said one week and meant it* | 2 h |
| `evasive` | never commits; ranges, effort, "will update soon" | 4 h |
| `deceptive` | claims dispatch that did not happen; professional and certain; admits nothing until confronted with tracking | 3 h |

This costs one prompt and buys two things: replies differ every run, so nothing can be hardcoded
against them; and the evasion reads like a real supplier email rather than a canned string.

### 9.3 Human takeover

Every supplier carries a `portalToken`. Opening `/supplier/[token]?run=<id>` on a phone makes you
that supplier. `POST /api/supplier` sets `humanControlled = true`, and from that point the persona
stays quiet — a human and an LLM must not both speak for the same supplier. The agent has no field
that distinguishes them. That is the point.

### 9.4 Injections — `injections.ts`

Six one-click disruptions from the console, each mapped to a hidden test:

`supplier_reneges` · `stock_correction` · `demand_spike` · `expedite_withdrawn` ·
`priority_flip` · `second_component_critical` (plus `quote_expiry`, available programmatically).

Each mutates the world in place, appends an `injection` audit entry, and revives a finished run.

An injection also calls **`invalidateAfterInjection()`**, which is the mechanism that makes
replanning visible. An external change means every conclusion the agent reached was reached against
a world that no longer exists — so every risk has its assumptions unverified and goes back to
`open` (a *resolved* risk included: the thing that closed it may not hold any more), and
`run.plans` is emptied because costed plans describe the old world.

This exists because expiry alone was too slow a mechanism. The sim clock advances only minutes per
tool call and assumptions carry a 12-hour TTL, so a 35-cycle shakedown produced **zero replans
against five injections**. A discrete event has to invalidate directly.

---

## 10. I/O — spreadsheets and email

### 10.1 Excel in, Excel out — `lib/io/spreadsheet.ts`

A great many mid-market manufacturers do not have a planning system; they have a workbook. Five
sheets, snake_case headers, styled and frozen:

`Inventory` · `Purchase Orders` · `Suppliers` · `Production Orders` · `Settings`

- `GET /api/erp/template` — a blank workbook with the right columns
- `POST /api/erp/import` — a workbook becomes the ERP; risks are cleared for re-evaluation
- `GET /api/erp/export` — live state (including agent-created POs) back out as `.xlsx`

### 10.2 Live sheet connection — `lib/io/sheetSync.ts`

Uploading a file is a one-off; a plant's workbook changes all day. A connection is a path on disk
or any URL serving `.xlsx` (including a Google Sheet's `/export?format=xlsx` link).

```
syncSheet()  — called at the top of EVERY perceive, plus a 5 s UI poll
  fetch → sha1 the bytes
  hash unchanged                  → nothing happened
  hash changed, semantic diff = 0 → a widened column or a resave. Not an audit line.
  hash changed, diff non-empty    → import, headline the diff in plain English
                                    ("COMP-104 usable stock 390 → 120"), CLEAR run.plans
```

Plans are cleared because the world moved underneath them and they no longer describe it. A buyer
editing a cell is a signal in exactly the way a supplier email is, and it arrives the same way.

### 10.3 Email — `lib/sim/mail.ts`

Genuine envelopes (`from`, `to`, `subject`, `threadId` derived from the subject with `Re:`/`Fwd:`
stripped) so the same messages could be posted over SMTP with no change upstream of the transport.

Real delivery is **off by default** and requires all three of `SMTP_ENABLED=true`,
`RESEND_API_KEY`, `SMTP_TEST_INBOX`. Even then it delivers **only to `SMTP_TEST_INBOX`** — an
address the operator owns — never to an address from the supplier table. The sandbox thread stays
the source of truth, so a delivery failure can never lose a message.

`POST /api/email/inbound` accepts a real reply back. The supplier id travels in the
`X-KEEL-Supplier` header, with a `[SUP-nn]` subject prefix as fallback for providers that strip
custom headers, and the sender address as a last resort. Deliberately tolerant: a reply in an
unexpected shape should still reach the agent rather than 400 into the void.

---

## 11. Measurement

### `scoreRun()` — the judging rubric, implemented

| Line | Weight | How it is computed |
|---|---|---|
| Production Continuity | 35% | fraction of high-priority orders no longer exposed at the current clock |
| Cost Control | 20% | executed spend vs the cheapest feasible plan **for the same component** — a run that solves three components is not penalised for spending more than one that solves one |
| Supplier Risk Handling | 15% | contradictions caught ÷ contradictions present, plus credit for vague replies flagged and gates applied |
| Tool Efficiency | 10% | calls used against budget, penalised for blocked repeats |
| Recovery & Replanning | 10% | replans ÷ injections |
| Audit Trail | 10% | fraction of tool calls carrying stated reasoning |

### `counterfactual()` — the no-agent baseline

Computed from `run.baseline`, the t=0 snapshot — **not** from live state. Reading the live world
would be self-defeating: by the time you look, the agent has already raised the POs that make the
problem disappear.

```
believed coverage   = currentStock ÷ dailyUsage     ← what the ERP screen shows a competent buyer
actual coverage     = computeCoverage() at t=0
downtime            = hours from firstStockout until relief could realistically land
panic freight       = shortfallUnits × baselinePrice × 0.9   (emergency air, line already down)
totalExposure       = downtime cost + panic freight
netSaved            = totalExposure − budget.spent
```

Plus eight module-by-module `catches`: what each one caught this run, and what follows if it had
not. Each is marked `fired` or not, so the page never claims a catch that did not happen.

---

## 12. Configuration

```bash
# .env.local

# Reasoning model — required for the agent to decide anything
LLM_PROVIDER=azure-ai          # azure-ai │ openai │ <openai-compatible>
LLM_MODEL=Kimi-K2.5
LLM_ENDPOINT=https://…
LLM_API_KEY=…
LLM_API_VERSION=2024-05-01-preview

# Persistence — omit for the in-memory dev store
KV_REST_API_URL=
KV_REST_API_TOKEN=

# Real email — omit to stay entirely inside the sandbox
SMTP_ENABLED=false
RESEND_API_KEY=
SMTP_TEST_INBOX=you@example.com   # the ONLY address that ever receives mail
SMTP_FROM=KEEL <onboarding@resend.dev>
```

Without an LLM key the app still runs: `perceive` is deterministic, so detection, reconciliation,
the risk register and the whole Engine Lab work. Only step 3 goes dark, and the console says so.

`/integrations` reports the live state of all four connections.

---

## 13. Running it

```bash
npm install
npm run dev          # http://localhost:3000
npm run typecheck    # tsc --noEmit
npm test             # tsx --test lib/solve/*.test.ts — the deterministic solvers
node scripts/shakedown.mjs 35 <seed> S7    # long-run soak: loops, budget, degradation
npx tsx scripts/demo-1-coverage.ts         # coverage reconciliation, no server
npx tsx scripts/demo-2-commitment.ts       # commitment parsing, no server
```

---

## 14. Design decisions, and what they cost

| Decision | Why | What it costs |
|---|---|---|
| One cycle per HTTP request | serverless-safe, resumable, watchable | the client drives the loop |
| Document store, not SQL | tens of records, one writer | no cross-run queries |
| LLM chooses tools, never computes | 55% of the rubric is arithmetic; a hallucinated number is fatal | more tools to maintain |
| Solvers are pure and seeded | testable, reproducible, demoable without the agent | no online learning |
| Suppliers are LLM personas | replies differ every run; nothing can be hardcoded against them | one extra model call per reply |
| Ground truth prefixed `_` and stripped at three layers | the sim can lie the way reality does | discipline required on every new tool |
| Polling, not sockets | simpler, adequate at 1–5 s | a little redundant traffic |
| Sim clock advanced by tool cost | reading is cheap, committing is expensive — as in a real plant | time is not wall-clock |
| Reply generated next cycle, in parallel | a serial second call made a tick 60 s+ and unwatchable | a one-cycle reply delay |

---

## 15. What is real and what is simulated

| Layer | Real | Simulated |
|---|---|---|
| Agent reasoning, tool use, planning, memory | ✅ | |
| Coverage / allocation / cost-of-delay arithmetic | ✅ | |
| Application, approvals, portal, audit, scoring | ✅ | |
| Spreadsheet import / export / live sync | ✅ | |
| Email envelopes and threading | ✅ | delivery is sandboxed unless explicitly enabled |
| Company data — inventory, POs, suppliers, production | | ✅ seeded, or your own workbook |
| Supplier behaviour | | ✅ LLM personas, or a real human at the portal |
| Carrier scan events | | ✅ |

No external supplier is contacted, no real ERP is written to, no payment is made.
