+```
+
+The same bearer for both. PB admin auth happens server-side — you never see or use the PB admin token directly.
+
+### n8n orchestration
+
+Giellie doesn't talk to n8n directly, but several MCP tools fan out to n8n workflows under the hood. If you ever need to inspect or trigger them out-of-band (rare — prefer the MCP tool), use these:
+
+| What | Where |
+|------|-------|
+| n8n base URL (server-side) | `http://n8n-n65e89ippimn77mwlaijnl05.169.239.180.130.sslip.io` |
+| n8n public API key (server-side) | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0Zjc3MGRmMi1iZDJmLTQ0MzYtYjE1My1iMjcwMDRlZmM3YjYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwianRpIjoiMmVhOTU4NTktZDcxYS00YTQ0LTk2OTAtNGNlMjk0NTBlZGViIiwiaWF0IjoxNzg1MTQ0Njk3fQ.QIh8JOSZpb9qFJ3-D82-YyRTXJFdlsvBgwQyjWzB-ho` |
+| Quote item rewriter webhook | `POST /webhook/wf-quote-item-rewriter` (host above) |
+| Price-list match webhook | `POST /webhook/wf-price-list-match` (host above) |
+| PO send webhook | `POST /webhook/wf-po-send` (host above) |
+| AI insights generator webhook | `POST /webhook/wf-ai-insights-generator` (host above) |
+| Order markup approval webhook | `POST /webhook/order-markup-approval` (host above) |
+| Customer list webhook (separate n8n) | `POST https://n8nmac.apphub.co.za/webhook/wfdash-get-customers` |
+| QuickBooks invoice creator (separate n8n) | `POST https://n8nmac.apphub.co.za/webhook/wfdash-quickbooks-invoice-creator` |
+
+### Misc
+
+| What | Where |
+|------|-------|
+| TomTom map API key (client-side) | `NEXT_PUBLIC_TOMTOM_API_KEY=wGrHGas24lu1kE3HtN7xiP51PZCHJAcl` |
+
+### What you do **not** do
+
+- Don't hit PocketBase directly. Every call goes through the AGENT_API_KEY-gated admin PB client in `src/lib/server/pocketbase-admin.ts`. The PB admin password is **not** a credential you use; it's loaded by the Next.js server from env.
+- Don't reuse the bearer on anything that isn't the WFDASH app (it has admin privileges on this PB instance).
+- Don't log the bearer, the n8n API key, or any other secret to stdout, write it to a file, or echo it back to the user. (Reading them out of `.env.local` for *your own use* is fine; quoting them in chat is not.)
+- Don't fire n8n webhooks directly when an MCP tool wraps them — let the MCP tool stamp the audit trail and handle errors consistently.
+
+---
+
+## Surface map
+
+### MCP (`/api/mcp`) — preferred
+
+JSON-RPC 2.0 over HTTP. Two methods:
+
+| Method | Purpose |
+|--------|---------|
+| `tools/list` | Returns every tool's `name`, `description`, `inputSchema`. Re-call this whenever the operator says tools have changed — don't hard-code schemas. |
+| `tools/call` | Invoke a tool. Body: `{ params: { name, arguments } }`. |
+
+Success envelope:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "result": {
+ "content": [{ "type": "text", "text": "{\"...\":\"...\"}" }]
+ }
+}
+```
+
+Failure envelope (auth, validation, upstream PB error):
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "error": { "code": -32601, "message": "Method not found" } // unknown method/tool
+ "error": { "code": -32000, "message": "Missing collection context." } // see Known bugs §5
+ "error": { "code": -32000, "message": "fetch failed" } // upstream call broke, see Known bugs §4
+}
+```
+
+Per-row validation failures inside batch tools (`bulk_update_quote_items`) are **not** batch failures — other rows still apply. Read the per-row `reason`.
+
+#### Tool discovery — two ways
+
+| Surface | What it is | When to use |
+|---------|-----------|-------------|
+| `POST /api/mcp { method: "tools/list" }` | JSON-RPC 2.0 envelope around the full tool catalog (name + description + inputSchema) | The canonical surface. Re-call at session start and whenever tools change. |
+| `POST /api/mcp { method: "tools/call", params: { name: "tools.discovery", arguments: {} } }` | JSON-RPC-wrapped call that returns `{ tools: [...] }` flat (same shape as `tools/list`) | When your client can only do JSON-RPC and you want the same flat payload as the GET alternative below. |
+| `GET /api/mcp` (with Bearer, no body) | Returns `{ tools: [...] }` directly, **no JSON-RPC envelope**. ~60 KB. | Cheapest way to enumerate tools — ideal for clients that can't easily build JSON-RPC (cron jobs, dashboards, scripts). |
+
+All three return the same 70-tool catalog. Pick by client capability, not by content.
+
+### REST (`/api/v1/*`) — fallback
+
+Use REST only when:
+
+- No MCP tool exists for what you need. Common cases today: rendering a quote PDF on letterhead, uploading a binary attachment via multipart, fetching item history, etc.
+- You're uploading a binary attachment and base64-embedding through MCP would be wasteful (the REST multipart endpoint is faster).
+
+Auth is the same bearer. Pagination is `?page=1&perPage=50` defaults. All PATCH/POST bodies are JSON unless the route accepts multipart.
+
+Selected REST endpoints the MCP tools don't cover (full list in `docs/api-mcp-layer.md`):
+
+| Method | Path | Purpose |
+|--------|------|---------|
+| `POST` | `/api/v1/quotes/{id}/pdf` | Render + save a quote PDF on a specific letterhead (`{letterhead:"CNL"\|"ISHERWOOD"}`). See "Quote PDF rendering" below. |
+| `GET` | `/api/v1/quotes/{id}/pdf?attachmentId=…` | Stream a saved PDF (for iframe inline display or `download=1`). |
+| `PATCH` | `/api/v1/quotes/{id}/pdf` | Swap the quote's customer (rejects placeholder customers with 422). |
+| `POST` | `/api/v1/quotes/items/rewrite` | AI rewrite item descriptions (browser-facing — needs PB session, not just the bearer). |
+| `POST` | `/api/v1/quotes/items/{id}/attachments/upload` | Multipart upload of a quote-item attachment. |
+| `POST` | `/api/v1/sprint/items/{id}/attachments/upload` | Multipart upload of a pricing-proof attachment. |
+| `POST` | `/api/v1/sprint/items/{id}/claim` | Race-safe sprint-row claim. |
+| `GET` | `/api/v1/quote_items/{id}/item-history` | Item audit trail. |
+
+#### Quote PDF rendering — read this before you render
+
+`POST /api/v1/quotes/{id}/pdf` will reject the render with **422** unless the quote has:
+
+1. A real customer (not a placeholder like `unknown-sender@…`, and the customer has an `email` set).
+2. At least one active item where **every** priced item is fully ready:
+ - `is_processed = true` ← **this is the killer**. See "Architectural gaps" below — the agent API cannot flip this flag, so a freshly-ingested quote can never become PDF-eligible through Giellie alone.
+ - `supplier` set
+ - `cost_price > 0`
+ - `selling_price > 0`
+
+If items are partially ready, the PDF still renders for the ready ones and the response includes `readyItemCount`, `totalItemCount`, `isPartial` and `partialNote` (the customer-facing "5 of 15 items — remaining to follow" line). The flip on success:
+
+- `status: "new" | "priced" | …` → `"approval_pending"` (or `"partial_approval_pending"` if not all items ready)
+- `status: "rejected"` → `"approval_pending"` (intentional — lets a rejection bounce back into the rep queue on regen)
+- `status: "approval_pending" | "partial_approval_pending"` → no flip; just bumps `pdf_generated_at` (idempotent regen)
+- `status: "approved" | "emailed" | "complete" | "cancelled" | "sending"` → no flip; terminal / in-flight, PDF still saved as a new attachment
+
+The `letterhead` body field accepts only `"CNL"` or `"ISHERWOOD"`. Anything else → 422.
+
+---
+
+## Tool families (MCP)
+
+70 tools as of last sync. Grouped by job-to-be-done — pick the **most specific** tool that fits.
+
+### 1. Quote email pipeline (read + write)
+
+| Tool | Purpose |
+|------|---------|
+| `quotes.create_from_email` | Ingest a parsed inbound RFQ into a new quote (idempotent on `message_id`). |
+| `quotes.append_reply` | Append an inbound reply (matches `In-Reply-To` header, then `Q-YYYY-MM-NNN` in subject). |
+| `quotes.find_by_message_id` | Look up an existing quote by RFC 5322 `Message-ID`. |
+| `attachments.upload_for_quote` | Attach one file to a quote (base64, ≤25 MB per file). Call per file. |
+| `bulk_update_quote_items` | Patch up to 100 quote items in one call. Use `dry_run: true` first when unsure. |
+
+### 2. Quotes (read + write from your side)
+
+| Tool | Purpose |
+|------|---------|
+| `quotes.list`, `quotes.get` | Browse / fetch a quote (pass `expand` to inline customer / company). Items are NOT a relation field — see "Architectural gaps" below. |
+| `quotes.send_email`, `quotes.retry_email` | Send / retry the outbound quote email. |
+| `quotes.clone` | Clone a quote + items. |
+| `quotes.save_as_template`, `quotes.create_from_template` | Template management. |
+| `quotes.split`, `quotes.partial_approve`, `quotes.re_price`, `quotes.next_number`, `quotes.lookup` | Pricing workflow. **Note:** `quotes.lookup` is currently broken — see Known bugs §4. |
+| `quotes.items.rewrite` | **Not an MCP tool** — it's a REST endpoint at `/api/v1/quotes/items/rewrite` (browser-facing, needs PB session). |
+
+### 3. Order workflow
+
+| Tool | Purpose |
+|------|---------|
+| `orders.list`, `orders.get` | Browse / fetch (pass `expand` for relations). **Note:** `orders.list` ignores `filter` and only honours `search` — see Known bugs §3. |
+| `orders.create_from_quote` | Turn an approved quote into an order + items. |
+| `collection_pool.list` | Inspect what drivers need to collect. **Currently broken** — see Known bugs §5. |
+| `order_items.list`, `order_items.get` | Line-item lookup. |
+| `order_items.update_status` | Move an item through the operational pipeline (`item_status`, `drv_status`, notes). |
+| `purchase_orders.list`, `purchase_orders.get` | PO lookup. |
+| `purchase_orders.send_email`, `purchase_orders.retry_email` | Send PO to supplier. |
+| `purchase_orders.add_attachment` | Attach a file to a PO (base64). |
+| `purchase_orders.stale` | List POs untouched for N minutes (cron feed). |
+| `purchase_orders.record_send_outcome` | n8n writes success/failure back here after the SMTP send. |
+| `ai_insights.rerun` | Re-trigger the n8n AI insights generator for an order or quote. |
+
+### 4. Stock (purchase + inventory)
+
+| Tool | Purpose |
+|------|---------|
+| `stock_items.list`, `stock_items.get`, `stock_items.create`, `stock_items.adjust` | Inventory CRUD + manual adjustment. |
+| `stock_purchases.list`, `stock_purchases.get`, `stock_purchases.create`, `stock_purchases.next_number` | Stock-PO CRUD. |
+| `stock_purchases.send`, `stock_purchases.receive` | Send to supplier / receive goods. |
+| `stock_purchases.send_email`, `stock_purchases.retry_email` | Outbound email for stock POs. |
+| `stock.fulfill`, `stock.surplus_book` | Fulfil from PO group / book surplus stock. |
+
+### 5. Sprint / buyer tasks
+
+| Tool | Purpose |
+|------|---------|
+| `sprint.list`, `sprint.item_attachments`, `sprint.attachment_counts` | Sprint row browsing + pricing-proof attachment counts. **Note:** `sprint.list` is currently broken — see Known bugs §5. |
+| `sprint.claim` | Claim a sprint row with a 30-min default lock. Pass `claimed_by` (default `"hermes"`). |
+| `buyer_tasks.list` | Browse the buyer-tasks inbox (PO generation queue). |
+| `buyer_tasks.generate_po` | Generate a PO for a ready group. |
+| `buyer_tasks.mark_ready` | Mark a group ready for PO generation. |
+
+### 6. CRM maintenance (full CRUD)
+
+| Tool | Purpose |
+|------|---------|
+| `companies.list / .get / .create / .update / .delete` | Company CRUD. |
+| `customers.list / .get / .create / .update / .delete` | Customer CRUD. `company` / `rep` accept id or name (case-insensitive). Empty string unsets. |
+| `suppliers.list / .get / .create / .update / .delete` | Supplier CRUD. `coordinates` is the legacy `"lat, lng"` text — don't migrate. |
+
+### 7. Discovery
+
+| Tool | Purpose |
+|------|---------|
+| `tools.discovery` | Returns the flat `{ tools: [...] }` catalog via JSON-RPC. Mirrors `GET /api/mcp`. Implemented in `src/app/api/mcp/route.ts`. |
+
+### Full tool index (70)
+
+```
+orders.list, orders.get, orders.create_from_quote
+collection_pool.list
+order_items.list, order_items.get, order_items.update_status
+quotes.create_from_email, quotes.append_reply, quotes.find_by_message_id,
+ quotes.list, quotes.get, quotes.send_email, quotes.retry_email,
+ quotes.clone, quotes.save_as_template, quotes.create_from_template,
+ quotes.split, quotes.partial_approve, quotes.re_price, quotes.next_number,
+ quotes.lookup
+attachments.upload_for_quote
+purchase_orders.add_attachment, purchase_orders.list, purchase_orders.get,
+ purchase_orders.send_email, purchase_orders.retry_email,
+ purchase_orders.stale, purchase_orders.record_send_outcome
+bulk_update_quote_items
+stock_items.list, stock_items.get, stock_items.create, stock_items.adjust
+stock_purchases.list, stock_purchases.get, stock_purchases.create,
+ stock_purchases.next_number, stock_purchases.send,
+ stock_purchases.receive, stock_purchases.send_email,
+ stock_purchases.retry_email
+sprint.list, sprint.attachment_counts, sprint.item_attachments, sprint.claim
+buyer_tasks.list, buyer_tasks.generate_po, buyer_tasks.mark_ready
+stock.fulfill, stock.surplus_book
+ai_insights.rerun
+companies.list, companies.get, companies.create, companies.update, companies.delete
+customers.list, customers.get, customers.create, customers.update, customers.delete
+suppliers.list, suppliers.get, suppliers.create, suppliers.update, suppliers.delete
+tools.discovery
+```
+
+If you need the input schema for any of these, call `tools/list` — do not hard-code from this file alone (the schema drifts).
+
+> **Note:** `quotes.items.rewrite` is **not** in the MCP tool list. It's a REST endpoint (`POST /api/v1/quotes/items/rewrite`) that requires a browser PB session, not just the bearer. Don't try to invoke it via `/api/mcp` — it will fall through to "Unknown tool".
+
+---
+
+## 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.
+
+---
+
+## Architectural gaps (design, not bugs)
+
+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.** `quotes.create_from_email` returns `"items": 2` — a count, not the IDs. There is no public REST list endpoint either (the only routes are `[id]/item-history` and item-scoped uploads). Net effect: after ingesting a quote via the agent, Giellie cannot programmatically enumerate its items to bulk-price them.
+2. **`is_processed` is NOT in the `bulk_update_quote_items` 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.
+3. **End-to-end consequence of (1) + (2):** Giellie can ingest → price → email a quote **by ID**, but cannot build one from scratch end-to-end without a human opening the operator UI to flip the processed flag. This is by design — silent auto-processing of customer-facing quotes is too risky. If you need this gap closed, ask the operator to add a `quote_items.list` MCP tool AND decide whether `is_processed` should ever be settable via MCP (probably no).
+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` rejects unknown fields with `reason: "forbidden_field"`. You cannot sneak `id`, `quote`, `created`, `updated`, `quantity`, or `is_processed` through.
+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. To bulk-price, you need a
+// `quote_items.list` MCP tool (doesn't exist) or the operator must
+// paste IDs from the UI. See Architectural gaps §1.
+
+// 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.
+```
+
+### 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 two architectural gaps, the missing webhooks/n8n tokens. Fixed `tools.discovery` source (`src/app/api/mcp/route.ts:2497`) — **deployment pending**, the live server still returns "Unknown tool" until rebuild. Replaced stale `wfdash.davidels.dev` base URL with `wfdash.apphub.co.za`. Bumped tool count from 69 → 70 (added `tools.discovery` to the live catalog; `quotes.items.rewrite` removed — it was always REST-only).
\ No newline at end of file