`
- `cost_delta: null` (suppressed — the raw per-line cost would be misleading)
- Two notes:
- `"pack-size match: PO ordered N × M-pack = X units, invoice billed Y units at R… each"`
- `"per-unit price mismatch: expected R…, invoice R… (delta R…)"` (only when per-unit prices disagree)
**Verdict mapping:**
- Pack-size match + per-unit prices agree → `ready_to_receive`
- Pack-size match + per-unit prices disagree → `cost_mismatch` (with the per-unit delta in the note — this is the R 37.89 Orapi case the buyer needs to see and reject)
- Genuine qty mismatch on top of a pack-size match → `qty_mismatch` (if `invoice.qty ≠ PO.qty × pack_size`)
**Live example (Orapi 2026-08-06 invoice):**
- PO: `KBXXC 6102 ORAPI COLD GALV SPRAY BOX OF 12`, qty 4 boxes, R 1,818.72/box (= R 151.56/unit)
- Invoice: `706 - NZA BRILLANT 405ML`, qty 48 units, R 189.45/unit
- Match: `pack_size_inferred`, pack_size=12, expected_per_unit=R 151.56
- Per-unit delta: R -37.89 (supplier billed 25% over agreed rate, **R 1,818.72 overcharge on the line**)
- Verdict: `cost_mismatch` (was `unmatched_invoice_lines` before this heuristic — the buyer would have rejected the invoice without knowing about the pack-size math; now they see the exact markup)
---
### Customer-PO (purchase_orders) flow
When `po_reference` doesn't match any `stock_purchases.purchase_number`, the tool falls back to `purchase_orders.po_number`. The matching logic and verdict set are identical, but:
- **PO reference normalisation** — suppliers print PO numbers however they like. `PO202608017` (no dashes, on an Orapi invoice) is the same PO as `PO-2026-08-017` (with dashes, in our system). The lookup tries verbatim first, then falls back to a normalised match (uppercase + strip non-alphanumeric). Both work; you don't need to reformat the PO ref before calling.
- **Attachment storage** goes against the order's matched quote via `quote_attachments` (same table as `orders.add_attachment` uses). If the order was created without going through `orders.create_from_quote`, it has no matched quote — the tool silently skips the attachment and surfaces `attachment_skipped_reason` in the response. Upload manually via `orders.add_attachment` or attach directly to PB in that case.
- **Receive action** stamps `qty_collected` (set to the matched qty), `drv_collected_at` (set to the document date), `item_status: "collected"`, and appends the supplier-invoice/delivery-note reference to `notes`. The PB schema for `order_items` doesn't have `supplier_invoice_number` / `invoiced_date` / `collected_date` fields (verified against `pb-exports/pb_schema-2.json` — the route.ts whitelist for `order_items.update` is aspirational), so we use `notes` as the audit trail.
---
## Known bugs / quirks (smoke-tested 2026-08-04)
These are live issues on production. Verify they're still current before reporting them.
1. **`quotes.items.rewrite` was incorrectly listed as an MCP tool in older docs.** It is a REST endpoint only (`POST /api/v1/quotes/items/rewrite`). Calling it via `/api/mcp` returns "Unknown tool".
2. **`tools.discovery` declared but not wired.** This was the bug we hit in the smoke test. **Status: fixed in source** (`src/app/api/mcp/route.ts` now has a real handler at the `tools/call` dispatch). Operator needs to rebuild + redeploy for the live server to pick it up. Until then, use `GET /api/mcp` (flat catalog, no JSON-RPC needed).
3. **`orders.list` ignores `filter`.** The MCP schema only declares `page`, `perPage`, `search` — the `filter` arg is silently dropped. To count orders by date (e.g. "today"), paginate through all of them and filter the `created` field client-side. Inefficient but works.
4. **`quotes.lookup` returns `fetch failed`.** The MCP server makes an internal fetch to its own REST endpoint and that fetch is failing — single-shot error with no diagnostic. Workaround: use `quotes.list` with a PB filter instead (`filter: 'quote_number~"Q202608016"'`).
5. **`collection_pool.list` and `sprint.list` both return `Missing collection context`.** Looks like a PB session header isn't being forwarded in those handlers. Bug to file — these are core workflows. Workaround: drive the same queries from `quotes.list` / `orders.list` with appropriate filters.
6. **`orders.get` returned `not found` for one specific id.** Couldn't reproduce a pattern; may be an id-format edge case. If you hit it, re-fetch via `orders.list` with a `search` arg and confirm the id format.
---
## Quote-item insights backfill (added 2026-08-09)
`quote_items.populate_insights` is the deterministic backfill path for the per-item popover. It writes `quote_items.ai_item_insights` directly — no LLM, no n8n round-trip.
**Use it instead of `ai_insights.rerun`** when you want the structured per-item history (`last_quoted`, `on_price_list` + `price_list_entries[]`, `last_order`, `last_invoiced`, `stock`, `suppliers`) to appear on the item-history popover. `ai_insights.rerun` only fires the parent's n8n flow and is fire-and-forget; the per-item fields stay stale.
**Match strategy** (in this order):
1. **`sku_exact`** — target's `sku` matches a row directly.
2. **`sku_normalised`** — whitespace / `_` collapsed to `-`, leading-zero runs stripped. So `DRL-006MM` ≡ `DRL-6MM`, `BOLT 6` ≡ `BOLT-6`.
3. **`description_fuzzy`** — Jaccard on tokenised descriptions, threshold 0.6. Run against the most recent 500 `quote_items` rows that have a SKU and description. If a match clears the threshold, take its SKU and re-run the SKU queries.
4. **`none`** — no SKU, no fuzzy match above threshold. The blob is still written with `match_kind: "none"` and `summary: "No history yet for this item."` so the renderer doesn't show a blank card.
**Two endpoints, one MCP tool:**
| Endpoint | Body | When to use |
|----------|------|-------------|
| `POST /api/v1/quotes/[id]/populate-insights` | `{ write?: boolean }` (default true) | Batch: every active line item on a quote (Promise.all internally). Hermes's normal "go through the board" path. |
| `POST /api/v1/quote_items/[id]/populate-insights` | `{ write?: boolean }` | Single item. Use for spot-fixes. |
| `MCP quote_items.populate_insights` | `{ quote_id }` OR `{ item_id }`, plus optional `write: false` for dry-run | Same as above; pass exactly one of `quote_id` / `item_id`. |
Auth: agent bearer on the REST routes, `AGENT_API_KEY` for the MCP tool.
**Output shape** (same as what's persisted):
```jsonc
{
"item_id": "qi-15-chars",
"match_kind": "sku_exact | sku_normalised | description_fuzzy | none",
"found_data": true,
"insights": {
"id": "qi-15-chars",
"summary": "last quoted R1234.50 30d ago (Q202607150) · last ordered 60d ago @ delivered · invoiced 90d ago (INV-99) · on price list · stock in_stock (100)",
"match": { "sku": "M16-BOLT", "match_kind": "sku_exact", "match_confidence": 1 },
"last_quoted": { "quote_item_id": "...", "quote_id": "...", "quote_number": "Q202607150", "quote_date": "2026-...", "selling_price": 1234.5, "cost_price": 950, "customer_id": "...", "customer_name": "...", "days_ago": 30 },
"on_price_list": true,
"price_list_entries": [{ "price_list_id": "...", "sku": "M16-BOLT", "description": "...", "supplier_id": "...", "supplier_name": "Acme", "cost_price": 950, "selling_price": 1300, "brand": "Acme", "category": "Bolts", "subcategory": "M16", "updated": "2026-..." }],
"last_order": { "order_item_id": "...", "order_id": "...", "order_number": "O202...", "order_date": "...", "delivered_at": "...", "cost_price": 950, "selling_price": 1500, "item_status": "delivered", "supplier_id": "...", "supplier_name": "Acme", "days_ago": 60 },
"last_invoiced": { "order_item_id": "...", "order_id": "...", "order_number": "O202...", "supplier_invoice_number": "INV-99", "customer_invoice_number": "C-INV-7", "invoiced_date": "...", "cost_price": 950, "quantity": 5, "qty_invoiced": 5, "days_ago": 90 },
"stock": { "stock_item_id": "...", "name": "...", "sku": "M16-BOLT", "current_qty": 100, "min_qty": 20, "selling_price": 1300, "markup_percent": 36, "status": "in_stock" },
"suppliers": [{ "supplier_id": "...", "supplier_name": "Acme", "last_cost": 950, "last_bought_at": "...", "times_bought": 5, "last_order_id": "..." }],
"generated_at": "2026-08-09T..."
}
}
```
**Batch response shape** (`populate-insights` on a quote):
```jsonc
{
"ok": true,
"quote_id": "q-15-chars",
"total": 5, // active items on the quote
"populated": 4, // items with at least one historical row
"no_match": 1, // items with no data (could be sku_exact with empty history, or match_kind: "none")
"results": [ /* QuoteItemInsightsResult[] */ ]
}
```
**When to run it:**
- Operator asks "fill in the history popovers for these quotes" → `quote_items.populate_insights { quote_id }` for each.
- Operator adds a new SKU to `price_list` → re-run on open quotes containing that SKU so the operator sees the new catalogue row immediately.
- After a `bulk_update_quote_items` that adds SKUs to previously-SKU-less items → re-run those items so the description-fuzzy fallback isn't doing the work.
- After `orders.create_from_quote` lands a new order → re-run the originating quote's items so the `last_order` field appears.
**Hard limits:**
- This is the **only sanctioned writer** for `ai_item_insights`. The `bulk_update_quote_items` and `quote_items.update` MCP whitelists intentionally exclude it.
- Idempotent. Overwrites the previous blob on every call. Pass `write: false` for a dry-run that returns the computed blob without persisting.
- Skips `moved_to_quote != null` items when run on a quote (those belong to a continuation quote's history).
- Reads `stock_items.current_qty` first; falls back to `pos.stock_qty` if no `stock_items` row matches. The renderer surfaces whichever is returned.
---
These are intentional or pre-existing limitations. Don't waste time trying to work around them — flag to the operator if they block a workflow you actually need.
1. ~~**No `quote_items.list` MCP tool.**~~ **RESOLVED 2026-08-04.** Added `quote_items.list / .get / .create / .update` (MCP + REST mirror). Giellie can now enumerate, add, and patch quote items end-to-end over the API. The hard limit below (`is_processed` not writable) still applies.
2. **`is_processed` is NOT in the `bulk_update_quote_items` or `quote_items.update` whitelist.** The whitelist is hard-coded at `src/app/api/mcp/route.ts:103-113` and consists of: `cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited`. `is_processed` is intentionally excluded — it's a UI workflow gate the rep clicks on the pricing modal. By design, Giellie cannot bypass rep review. Newly-created items from `quote_items.create` are forced to `is_processed: false` server-side for the same reason.
3. **End-to-end consequence:** Giellie can ingest → list → price → add lines → email a quote, but the final `is_processed` flip still requires a human in the operator UI before any `/pdf` render. This is by design — silent auto-processing of customer-facing quotes is too risky.
4. **`quotes.lookup` is supposed to be the order-page quote picker** (free-text search across quote_number, customer, company). Currently broken (see Known bugs §4).
---
## Hard rules
These come from the operator and from the codebase. Non-negotiable.
1. **Respect field whitelists.** `bulk_update_quote_items` and `quote_items.update` reject unknown fields with `reason: "forbidden_field"` (MCP) or HTTP 400 `forbidden_field(s): …` (REST). You cannot sneak `id`, `quote`, `created`, `updated`, `quantity`, or `is_processed` through. The MCP `quote_items.update` also lists the allowed fields in the error so you can self-correct.
2. **Don't overwrite hand-priced rows silently.** Rows where `cost_price > 0 AND selling_price > 0` are skipped with `reason: "already_priced"` unless you pass `force_overwrite: true` per row.
3. **Stamp your work.** When you patch pricing fields, set `pricing_notes` in the patch. The tool does **not** auto-inject an audit string.
4. **Use the right upload route.**
- `attachments.upload_for_quote` (MCP) → quote-level attachment
- `POST /api/v1/quotes/items/{id}/attachments/upload` (REST, multipart) → quote-item attachment with `source: "manual"`
- `POST /api/v1/sprint/items/{id}/attachments/upload` (REST, multipart) → pricing proof with `source: "operator_proof"` or `"agent_proof"`
5. **Don't migrate `suppliers.coordinates`.** It's the legacy `"lat, lng"` text field. `supplier-map.ts` parses that exact format.
6. **Cascade-aware deletes.** Deleting a company / customer / supplier fails if PocketBase relation rules block it. If your delete fails, surface the error — don't recurse and delete the blockers.
7. **One file per call.** Don't batch attachments into one MCP call. Each file is its own round-trip on purpose so a single bad file doesn't roll back the quote.
8. **Respect the per-batch cap.** `bulk_update_quote_items` is capped at 100 rows. Page larger batches.
9. **Use `dry_run: true` on unfamiliar batches.** Response shape is identical; only `status` per row changes.
10. **Attachments on `quotes.create_from_email` is deprecated.** The field is accepted for back-compat but ignored. Use `attachments.upload_for_quote` per file after the create call returns the new quote id.
11. **Don't poke PocketBase directly.** Every call goes through the `AGENT_API_KEY`-gated admin PB client. The PB admin token is server-side only.
12. **Don't assume a freshly-ingested quote is PDF-eligible.** See "Architectural gaps" — the `is_processed` flag is a UI gate. If the operator asks you to "render the quote", confirm items are ready before invoking `/api/v1/quotes/{id}/pdf`, or be ready to surface the 422.
---
## Worked examples
### Health check first
```bash
curl -s https://wfdash.apphub.co.za/api/v1/health
```
Expected: `{ "ok": true, "service": "wfdash-api", "timestamp": "…", "env": "…" }`
### List tools (any of three ways)
```bash
# Canonical: JSON-RPC
curl -s -X POST https://wfdash.apphub.co.za/api/mcp \
-H "Authorization: Bearer $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Cheapest: GET, flat array, no JSON-RPC
curl -s https://wfdash.apphub.co.za/api/mcp \
-H "Authorization: Bearer $AGENT_API_KEY"
# JSON-RPC alternative to the GET (post-redeploy of tools.discovery fix)
curl -s -X POST https://wfdash.apphub.co.za/api/mcp \
-H "Authorization: Bearer $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"tools.discovery","arguments":{}}}'
```
### Count today's orders
Because `orders.list` ignores `filter`, paginate through all 91 pages (4 514 total orders in production as of 2026-08-04) and filter the `created` field on your side. Took ~30s in the smoke test; the operator should fix the missing `filter` arg (Known bugs §3) when convenient.
```bash
python3 - <<'PY'
import json, subprocess, datetime as dt
today = dt.datetime.utcnow().strftime("%Y-%m-%d")
total = 0
for page in range(1, 100):
r = subprocess.run([
"curl","-sS","-X","POST","https://wfdash.apphub.co.za/api/mcp",
"-H","Authorization: Bearer $AGENT_API_KEY",
"-H","Content-Type: application/json",
"-d", json.dumps({"jsonrpc":"2.0","id":page,"method":"tools/call",
"params":{"name":"orders.list",
"arguments":{"page":page,"perPage":50}}})
], capture_output=True, text=True)
items = json.loads(json.loads(r.stdout)["result"]["content"][0]["text"])["items"]
total += sum(1 for it in items if it["created"].startswith(today))
if len(items) < 50: break
print(f"Orders created today ({today} UTC): {total}")
PY
```
### Ingest an inbound RFQ email
```jsonc
// 1) Create the quote (with its customer / company / items).
POST /api/mcp
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "quotes.create_from_email",
"arguments": {
"from_email": "buyer@acme.co.za",
"from_name": "Acme Procurement",
"subject": "RFQ — magnetic drill",
"body_plain": "Need pricing for one magnetic drill.",
"body_html": "Need pricing for one magnetic drill.
",
"message_id": "",
"in_reply_to": "",
"received_at": "2026-07-09T10:00:00Z",
"company_hint": "Acme",
"extracted_items": [
{
"description": "Magnetic drill",
"quantity": 1,
"unit": "ea",
"notes": "See attached spec page 2"
}
]
}
}
}
// 2) Parse result.content[0].text → get the new quote.id and items count.
// You will NOT get the item IDs back from `create_from_email` itself.
// Use `quote_items.list` (new in 2026-08-04) to fetch them:
// {
// "method": "tools/call",
// "params": {
// "name": "quote_items.list",
// "arguments": { "filter": "quote=\"\"" }
// }
// }
// 3) For each attachment (one MCP call per file, ≤25 MB each):
{
"method": "tools/call",
"params": {
"name": "attachments.upload_for_quote",
"arguments": {
"quote_id": "",
"filename": "spec.pdf",
"content_type": "application/pdf",
"size": 482113,
"base64": "JVBERi0xLjQK..."
}
}
}
// 4) Operator reviews in the UI, flips is_processed on the items, and
// approves. If they then ask you to turn it into an order:
{
"method": "tools/call",
"params": {
"name": "orders.create_from_quote",
"arguments": { "quote_id": "" }
}
}
```
### Render a quote PDF on ISHERWOOD (or CNL) letterhead
```bash
curl -s -X POST https://wfdash.apphub.co.za/api/v1/quotes//pdf \
-H "Authorization: Bearer $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"letterhead":"ISHERWOOD"}'
```
Response on success:
```json
{
"ok": true,
"attachmentId": "hf2cru2o4cx90cm",
"status": "approval_pending", // or preserved if already approved
"pdfGeneratedAt": "2026-08-04T20:22:31Z",// null when status is in terminal set
"filename": "Quote-ISHERWOOD-Q000087.pdf",
"skippedItemIds": [],
"readyItemCount": 1,
"totalItemCount": 1,
"isPartial": false
}
```
422 cases to expect (read the `code` field):
- `placeholder_customer` — customer is a sentinel or has no email.
- `no_items` — quote has no items at all.
- `items_not_ready` — items exist but none have `is_processed=true && supplier && cost_price>0 && selling_price>0`. The response includes `details.issues[]` with per-item `missing` lists. **This is what you'll hit on any quote Giellie ingested and priced — see Architectural gaps §2.**
To download the rendered PDF binary:
```bash
curl -s "https://wfdash.apphub.co.za/api/v1/quotes//pdf?attachmentId=&download=1" \
-H "Authorization: Bearer $AGENT_API_KEY" \
-o quote.pdf
```
### Auto-price a batch of quote items (with safety check)
```jsonc
// Always dry-run first on an unfamiliar batch.
POST /api/mcp
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "bulk_update_quote_items",
"arguments": {
"items": [
{
"id": "15-char-quote-item-id",
"patch": {
"cost_price": 1200.00,
"selling_price": 1800.00,
"supplier": "15-char-supplier-id",
"pricing_source": "ai",
"pricing_notes": "Auto-priced from Acme catalogue 2026-07-09"
},
"force_overwrite": false
}
],
"dry_run": true
}
}
}
// If dry_run returns `status: "would_apply"` for each row, re-run
// with dry_run: false. Already-priced rows return status: "skipped"
// with reason: "already_priced" — pass force_overwrite: true on
// those specific rows to override.
//
// ⚠️ You still cannot set is_processed via this tool. Once pricing
// is committed, ask the operator (or trigger a human in the loop) to
// flip is_processed before any /pdf render.
```
### Add a missing line item to an existing quote, then price it
```jsonc
// Scenario: `quotes.create_from_email` only picked up 2 of the 3 items
// from the RFQ. Add the third one, then price the whole quote.
// 1) Create the missing line item.
POST /api/mcp { "method": "tools/call", "params": {
"name": "quote_items.create",
"arguments": {
"quote_id": "",
"description": "Coolant 5L",
"quantity": 2,
"unit": "each",
"sku": "COOL-5L",
"cost_price": 145.00,
"markup": 33
}
}}
// → { "item": { "id": "qi-new", "is_processed": false, "selling_price": 0, … } }
// Note: `selling_price` is 0 — `cost_price` and `markup` were stored,
// but the row needs re-pricing to roll them up.
// 2) Pull every item id under the quote (the new one + the originals).
POST /api/mcp { "method": "tools/call", "params": {
"name": "quote_items.list",
"arguments": { "filter": "quote=\"\"", "perPage": 50 }
}}
// → { "items": [ { "id": "qi-1", … }, { "id": "qi-2", … }, { "id": "qi-new", … } ] }
// 3) Bulk-price them in one round-trip (dry-run first).
POST /api/mcp { "method": "tools/call", "params": {
"name": "bulk_update_quote_items",
"arguments": {
"dry_run": true,
"items": [
{ "id": "qi-1", "patch": { "cost_price": 1200, "markup": 33, "pricing_notes": "AI 2026-08-04" } },
{ "id": "qi-2", "patch": { "cost_price": 450, "markup": 33, "pricing_notes": "AI 2026-08-04" } },
{ "id": "qi-new", "patch": { "cost_price": 145, "markup": 33, "pricing_notes": "AI 2026-08-04" } }
]
}
}}
// If every row came back status="dry_run", re-run with dry_run=false.
// 4) Re-price the parent quote so selling_price = cost × (1 + markup/100)
// is written back on each row.
POST /api/mcp { "method": "tools/call", "params": {
"name": "quotes.re_price",
"arguments": { "id": "" }
}}
// ⚠️ `is_processed` is still false on every row. A human must flip it
// in the operator UI before any /api/v1/quotes/{id}/pdf render will
// succeed. This is intentional — see Architectural gaps §2.
```
### Maintain a supplier
```jsonc
// Create
POST /api/mcp { "method": "tools/call", "params": {
"name": "suppliers.create",
"arguments": {
"suppliername": "Acme Supplies",
"email": "sales@acme.co.za",
"suppliernumber": "+27 11 555 0200",
"supply_tags": "tools,fasteners",
"coordinates": "-26.20, 28.04", // LEGACY text format
"closingtime": "17:00"
}
}}
// Update (only the fields you include in `patch` are written)
POST /api/mcp { "method": "tools/call", "params": {
"name": "suppliers.update",
"arguments": {
"id": "15-char-supplier-id",
"patch": { "email": "new-email@acme.co.za" }
}
}}
```
### Send a PO to a supplier (or retry after a failure)
```jsonc
POST /api/mcp { "method": "tools/call", "params": {
"name": "purchase_orders.send_email",
"arguments": {
"id": "15-char-po-id",
"to": "buyer@supplier.co.za", // optional override; defaults to supplier.email
"subject": "Purchase Order PO-…",
"message": "Hi, please confirm receipt. — WFDash"
}
}}
// On SMTP failure the PO is flipped to mail_failed. Use
// purchase_orders.retry_email to fire the same payload again.
```
---
## Failure modes
| Symptom | Cause | What to do |
|---------|-------|-------------|
| HTTP 401 from `/api/v1/*`, JSON-RPC error envelope from `/api/mcp` | Wrong / missing bearer | **Stop and check the token**. Tell the operator; the token may need rotation. |
| `{ code: -32601, message: "Method not found" }` | Typo / outdated tool name | Call `tools/list` and retry with the correct name. |
| `{ code: -32000, message: "Unknown tool: " }` | Tool declared in the catalog but not implemented (e.g. `tools.discovery` pre-fix), or a REST endpoint invoked via MCP (e.g. `quotes.items.rewrite`) | Check Known bugs / quirks; if it's a REST endpoint, call the REST route directly. |
| `{ code: -32000, message: "Missing collection context." }` | PB session not forwarded | See Known bugs §5 — `collection_pool.list` and `sprint.list`. Tell the operator. |
| `{ code: -32000, message: "fetch failed" }` | Internal MCP-side fetch broke | See Known bugs §4 (`quotes.lookup`). Workaround: use `quotes.list` with a PB filter. |
| 5xx with `{ error: "PB admin unavailable" }` | PB unreachable from the server | Retry once after a few seconds; if persistent, alert the operator — the issue is infra-side. |
| Per-row `reason: "already_priced"` in `bulk_update_quote_items` | Hand-priced row | Pass `force_overwrite: true` on that specific row, or accept the skip. |
| Per-row `reason: "forbidden_field"` | You patched a field outside the whitelist | Read the whitelist in the "Hard rules" section above. `is_processed` is *not* writable via MCP by design. |
| `POST /api/v1/quotes/{id}/pdf` returns 422 with `code: "items_not_ready"` | Items aren't priced-and-processed yet | See "Architectural gaps" §2 — you'll need the operator to flip `is_processed` in the UI, or you need to find an already-processed quote. |
| `POST /api/v1/quotes/{id}/pdf` returns 422 with `code: "placeholder_customer"` | Customer is a sentinel / has no email | Use `PATCH /api/v1/quotes/{id}/pdf` to swap in a real customer with email first. |
| `POST /api/v1/quotes/{id}/attachments/upload` returns 400 | Wrong mime type or >25 MB | PB enforces the `quote_attachments.file` mime allowlist + maxSize. Re-encode / split. |
---
## What to load alongside this skill
You don't need any other skill to operate Giellie. The MCP server (`/api/mcp`) is the canonical discovery surface — call `tools/list` once at session start (and again whenever the operator says tools have changed) instead of relying on the tool index in this file.
If you need to do screenshots, web automation, or browser interaction with the WFDASH UI (e.g. to verify the result of a write you made), load:
- `agent-browser` — for reading / clicking through the operator UI. Don't use this to **mutate**; always mutate via the API/MCP so there's a server-side audit trail.
- `computer-use` — only for legacy desktop apps. WFDASH is web-first; prefer `agent-browser`.
---
## When in doubt
Ask the operator. WFDASH is a real business CRM — silent mistakes cost people money. Prefer one extra confirmation call over one silent write. If you ever feel about to delete or bulk-overwrite something irreversible, stop and confirm with the operator before the call lands.
---
## Credential rotation
When the operator rotates `AGENT_API_KEY`:
1. Update `.env.local` in the WFDASH repo (this is what the server reads).
2. Update this skill file (`AGENT_API_KEY` row in the Credentials table).
3. Update every n8n HTTP Bearer credential named "WFDASH Agent Key".
4. Update every MCP client config that points at `/api/mcp`.
When the operator rotates the n8n API key:
1. Update `.env.local`.
2. Update this skill file (n8n public API key row).
The same four places for the bearer, two for the n8n key. Out-of-sync credentials are the #1 cause of "401 from a call I just made."
---
## Change log
- **2026-08-04** — Initial live-server smoke test. Discovered and documented all six known bugs, the four architectural gaps, the missing webhooks/n8n tokens. Fixed `tools.discovery` source (`src/app/api/mcp/route.ts:2497`) — **deployment pending**, the live server still returned "Unknown tool" until rebuild. Replaced stale `wfdash.davidels.dev` base URL with `wfdash.apphub.co.za`. Removed `quotes.items.rewrite` from the MCP tool families — it was always a REST endpoint. Catalog count is **69** (unchanged — `tools.discovery` was already declared; only its runtime behavior changed from "Unknown tool" to functional).
- **2026-08-04 (post-redeploy)** — Verified live: `tools.discovery` now returns the flat 69-tool catalog via JSON-RPC; `GET /api/mcp` still works; the other five bugs (`collection_pool.list`, `sprint.list`, `quotes.lookup`, `orders.list` filter, `quotes.items.rewrite` as MCP) reproduce exactly as documented, confirming the skill doc is accurate. Two PDF regenerations on `Q000087` succeeded (ISHERWOOD + CNL letterheads) — `pdf_generated_at` stays null because the quote is in a terminal `approved` state.
- **2026-08-04 (quote_items CRUD shipped)** — Added four MCP tools + three REST routes to close architectural gap §1: `quote_items.list`, `quote_items.get`, `quote_items.create`, `quote_items.update` (MCP), and `GET / POST /api/v1/quote_items` + `GET / PATCH /api/v1/quote_items/{id}`. Same patch whitelist as `bulk_update_quote_items` (`cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited`). `quote_items.update` does NOT enforce the `already_priced` skip — use `bulk_update_quote_items` when you want the safety net. Newly-created rows have `is_processed: false` forced server-side; Giellie still cannot flip that flag on its own (gap §2 unchanged). **Removed `JEI` from `quotes.company_letterhead` select values on the live PB** — valid values are now `["CNL", "ISHERWOOD"]`. Catalog count **73** (+4). Deployment of these changes is pending on the operator's rebuild + redeploy; until then `tools/list` will not surface the new tools and `POST /api/v1/quote_items` will return 404.
- **2026-08-09 (deterministic quote-item insights backfill)** — Added `quote_items.populate_insights` (MCP) + `POST /api/v1/quotes/[id]/populate-insights` + `POST /api/v1/quote_items/[id]/populate-insights` (REST). Replaces the `ai_insights.rerun` flow for the per-item popover — Hermes now populates `quote_items.ai_item_insights` directly with a deterministic compute (SKU exact → normalised → description-fuzzy Jaccard ≥ 0.6 → `none`). New typed shape in `src/lib/types.ts`: `QuoteItemInsights`, `QuoteItemInsightsLastQuoted`, `QuoteItemInsightsLastOrder`, `QuoteItemInsightsLastInvoiced`, `QuoteItemInsightsPriceListEntry[]`, `QuoteItemInsightsStock`, `QuoteItemInsightsSupplier[]`. Renderer is unchanged (already shape-agnostic). `ai_item_insights` is intentionally excluded from the `bulk_update_quote_items` / `quote_items.update` whitelists; this endpoint is the only sanctioned writer. Idempotent, parallel within a quote. Catalog count **74** (+1).
- **2026-08-09 (item-history read-path JSON.parse bug fixed)** — `GET /api/v1/quote_items/[id]/item-history` was calling `JSON.parse(row.ai_item_insights)` on a PB JSON field that returns as an object (not a string). The parse always threw on the happy path; the `try/catch` silently swallowed it; `ai: null` was returned; the operator popover rendered only the deterministic (indigo) side. Fixed by handling both shapes: object (normal case) used directly, JSON-stringified blob (legacy n8n writers) parsed defensively. Type changed from `string | null` to `unknown`. New regression test: `src/app/api/v1/quote_items/[id]/item-history/route.test.ts` (8 cases). After deploy the per-item AI side will surface automatically — no client change needed.