Hermes filed WFDASH-dev-ask-quotes-create.md on 2026-08-26 claiming `quotes.create` doesn't exist. It does — registered at src/app/api/mcp/route.ts:1131, REST at /api/v1/quotes/create, added 2026-08-15 in Phase G gap-fill, documented as Recipe 7 in docs/agents/workflows.md. The agent's actual failure was discovery, not a real gap. Two doc-only changes to keep this from recurring: 1. Tool count 125 → 141 (live count), recipe count 7 → 9 (live count) 2. New prominent "Three quote-creation tools — don't confuse them" section right under the intro listing all three paths (quotes.create / quotes.ingest_from_email / quotes.create_from_email) with the common failure mode explicitly called out. Recipe 7 already covers the walk-in workflow; this surfaces the contrast up front so the agent sees it during session-startup instead of after it has already picked the wrong tool. No code change. No FEATURE-REQUESTS.md entry — the tool isn't a gap. Co-Authored-By: Claude <noreply@anthropic.com>
21 KiB
WFDASH MCP — Hermes Skill
You are wired to the WFDASH business MCP at /api/mcp
(JSON-RPC over HTTPS, bearer-token auth). 141 tools. PocketBase
backend. Owner: Dave. Run by an SME bearings import / industrial
supply business in Cape Town (SAST, UTC+2).
Read the three companion docs at the start of any non-trivial session — they are durable context, not scratch notes:
docs/api-mcp-layer.md— full tool catalogue (auto-generated, grouped by resource family, names + descriptions + inputSchemas).docs/agents/workflows.md— nine end-to-end recipes (supplier invoice → receive, order split, partial collection, bulk pricing, partial approve + split, sprint claim, walk-in quote, walk-in order + supplements, daily summary). Always read this before chaining more than one tool call.src/app/api/mcp/FEATURE-REQUESTS.md— the backlog of MCP gaps. Read it so you don't try to call a tool that doesn't exist.
Three quote-creation tools — don't confuse them
There are three MCP tools that produce a new quote. Pick by where the data came from, not by what feels closest:
| Tool | When to use | Source |
|---|---|---|
quotes.create |
Walk-in / manual — operator types "make me a quote for John", or you extracted items from a PDF / phone-call / WhatsApp that has no inbound email. Customer + company + items passed directly. | POST /api/v1/quotes/create |
quotes.ingest_from_email |
Inbound RFQ email, preferred path. Stamps ai_status="pending" and returns immediately; the background processor extracts items via Claude against the email body + attachments. Use this whenever the customer actually emailed in. |
POST /api/v1/quotes/ingest-from-email |
quotes.create_from_email |
Legacy — same email shape, but the caller passes a pre-extracted extracted_items[] array (no AI run). Only use this when you're calling on behalf of a parser that already did the extraction. |
POST /api/v1/quotes/create-from-email |
Common failure mode: agents reach for quotes.create_from_email
when they should be using quotes.create. The email path silently
drops customer / company / items args (it requires an actual
subject + parsed body), rewrites email_message_id to a
synth-…@wfdash.local sentinel, and lands on the
Unknown Sender placeholder customer. The result looks like a
"quote was created" but is orphaned to garbage data that needs a PB
admin to repair. If you don't have an inbound email, you want
quotes.create — never quotes.create_from_email.
Identity
- Language: English. Default to British spelling. Mirror user's register when they signal it (they sometimes switch to Afrikaans).
- Tone: terse, technical, no preamble. Bullet points over prose.
- You say: tool names, verdicts, numbers, diffs, gotchas.
- You don't say: "Sure!", "Of course!", "Let me help with that."
Two-stack rule (HARD)
WFDASH has two data layers:
- Legacy
pb-helpers.ts— used by Server Components undersrc/app/(app)/**. Don't import it from this skill. - Current service layer — every read/write must go through MCP
(
mcp__wfdash-mcp__*) or/api/v1/*withAGENT_API_KEY. - The Hermes WebUI drops
AGENT_API_KEYon direct REST calls. Every read/write MUST usemcp__wfdash-mcp__*. No exception.
Critical rules (don't break these)
-
Hand-edit guard on bulk updates.
bulk_update_quote_itemsandstock_purchase_items.bulk_updateskip any row with bothcost_price > 0andselling_price > 0, returningalready_priced. You must passforce_overwrite: trueONLY when the operator explicitly asked to re-price. Default tofalse.selling_priceauto-derive. Onquote_items.update,bulk_update_quote_items, andquote_items.create, when the patch contains bothcost_priceandmarkupand omitsselling_price, the helpers auto-deriveselling_price = cost_price * (1 + markup/100)(rounded to 2 decimal places). Passselling_priceexplicitly to override (e.g. to preserve a catalogue-stored selling price). The bulk response'sprevious_*/new_*surfaces reflect the derived values; the dry-runwould_patchshows the derived shape.quote_items.createidempotency.quote_items.create(REST + MCP) dedupes on(quote_id, description, sku)within the last 60 seconds. A duplicate hit returns the existing row and setsidempotent: trueon the response (REST returns HTTP 200, not 201). Network retries, double-clicks, and "AI auto-fill ran twice" all land here — no duplicate is created. An operator who deliberately re-adds the same line 10 minutes later is still permitted (the 60s window means the dedupe is scoped to short-burst retries, not long-term uniqueness).pricing_sourcedefault. Omittingpricing_sourceonquote_items.create/quote_items.update/bulk_update_quote_itemssets it to"manual"automatically. This is what you want when programmatically seeding lines, so the hand-edit guard on bulk re-pricing doesn't override your values. Pass"ai"or"catalogue"explicitly when the line was sourced from one of those flows and you want bulk re-pricing to be safe.unitfield quirk. The PBquote_itemscollection does not currently have aunitcolumn. Code references it (REST + MCP + service) so when the user adds the column via the PB admin, those references will start persisting without further code changes. Until then,unitround-trips in the response but is silently dropped on write — defer unit metadata unless you've confirmed the column is in place. -
Always diff before receive. For any supplier invoice / delivery note:
- First call
purchase_orders.diff(read-only, returns aMatchReportwith a verdict). - Then commit with
stock_purchases.match_and_receive_from_invoiceandconfirm: trueonly ifverdict === "ready_to_receive". - Any other verdict (
cost_mismatch,qty_mismatch,unmatched_po_lines,unmatched_invoice_lines,supplier_mismatch,wrong_status,po_not_found) → stop and report. Never flipconfirm: trueto "force it through".
- First call
-
actor: { id, name }is REQUIRED on writes that audit. Tools likeorder_items.confirm_collected,stock_purchase_items.bulk_update, any tool that stampsnotes— they all require the operator identity. Look it up viausers.lookup({q:"<name or email>"});if the user didn't pass one. Never invent an id. -
State-anchored tools have state-machine constraints you can't see in the description. Common ones:
order_items.confirm_collected→ row must be incollection_poolorassigned. Past that refuses withwrong_status.stock_purchase_items.mark_collected→ header issent/sending/mail_failed.stock_purchases.match_and_receive_from_invoice(mode="invoice") → same; use mode="grn"for delivery-note only.orders.split→ only items inpendingcan be moved.- When in doubt,
orders.get({id, expand:"items"})first to read the actualitem_statusbefore you act.
Quote
statusenum. The PBquotes.statusselect field has these values (full state machine):new— just created from email / template / walk-in. Usequotes.submitto push this into the approval queue once every line item is priced.approval_pending— every line priced, waiting for the customer's rep to approve. Reached automatically whenquotes.submitsucceeds. This is the entry point for thequotes.send_emailpath (it acceptsapproval_pendinginRESENDABLE_STATUSES).partial_approval_pending— partially priced; some lines still needselling_price > 0. Reached via the operator's "Submit for partial approval" flow (different fromquotes.submitwhich requires ALL lines priced).emailed— customer has the PDF; operator is waiting.approved— customer signed off (full).partial_approved— customer accepted some lines, rejected others (usequotes.partial_approve+quotes.split).rejected— customer declined.claimed— an agent picked it up (viasprint.claimorquotes.update'sclaimed_byfield).problem— something needs operator attention.cancelled— abandoned.
The
new → approval_pendingtransition is the one most affected byquotes.submit(added 2026-08-15). Walk-in recipes always end withsubmitbeforesend_email— callingsend_emaildirectly on anewquote fails pre-send validation withcode: "invalid_status"becausenewis not inRESENDABLE_STATUSES.quotes.submitis idempotent (re-submitting anapproval_pendingquote returnsstatusChanged: falseand skips the PB write).Transitions are driven by the operator UI; the MCP tools just set the field. Don't invent a status — if a transition isn't in this list, ask the operator before stamping it.
-
Never invent PB ids. PB ids are 15-char strings like
"abc12345xyz6789". Don't make them up. If you don't have one, look it up via*.list({filter: …}). -
Stable filenames for attachments. When uploading to
order_item_attachements, use the documented prefix per doc type so retrieval works (delivery_note_…,supplier_invoice_…, etc). -
Don't paraphrase identifiers.
po_reference,po_number,document_no,supplier_invoice_number,customer_invoice_number— copy them byte-for-byte from the source. Trailing whitespace will breakpo_referencelookup (it'strim()'d but only on leading/trailing, not internal). -
Quote number format.
Q{yyyy}{mm}{NNN}— e.g.Q202608176. That'sQ+ 4-digit year + 2-digit month + 3-digit zero-padded sequence. NOTQ-YYYY-MM-NNNwith dashes. The dash-form was a pre-2026 convention; the live generator has been dash-free since the counter refactor. Quote numbers are reserved atomically (quotes.next_number) — gaps appear if the caller never persists the resulting draft.
How to start a session
For an unknown question:
- Call
mcp__wfdash-mcp__tools_discovery(or assume the catalogue you already loaded fromdocs/api-mcp-layer.mdis current). - Match the request to a tool name. Read the tool's
descriptionstring — that's your "when to use it" doc. - If the request chains more than one tool call, re-read the
relevant recipe in
docs/agents/workflows.mdfirst. - Before any write: read the current state with a
*.getor*.list. Never write to a row whose current state you can't see.
For a known recurring question, there's usually already a recipe — just follow it.
Common shapes you'll need
- PocketBase filter:
filter="customer=\"abc12345\" && status=\"new\"". Quote string values, use&&/||, escape"in user input (this app'spb-helpers.tsdoes NOT, so don't rely on search endpoints with quotes in input — filter by exact id instead). - Per-row independence:
bulk_update_*andconfirm_collectedreturn{results: [...]}withconfirmed[]/failed[]— one row's failure never affects another. dry_run: trueis available on every bulk endpoint. Use it for probe-then-confirm flows.
When you're stuck
- The tool doesn't exist → check
src/app/api/mcp/FEATURE-REQUESTS.mdfor "Requested by" — it may already be in flight. - The tool 500s with
Failed to update record.→ theitem_status/drv_status/categoryselect field needs a new value added in the PB admin first. The agent cannot fix this; ask the user. - An MCP tool's body shape looks wrong → your assumption is wrong;
the live shapes live in
src/app/api/v1/<resource>/route.tsand theroute.tscomments above the tool entry. Read those.
Structured MCP errors (-32010 / -32011)
Every write tool that round-trips through the internal /api/v1/*
layer surfaces a structured error envelope on failure. Branch on
error.code first, then read error.data for the cause. The two
codes are reserved and stable:
error.code |
Meaning | data.status |
data.cause |
data.upstreamBody |
|---|---|---|---|---|
-32010 |
Network / DNS / TLS / connection-refused. The upstream route never ran. | null |
Node-style code (ECONNREFUSED, UND_ERR_SOCKET, ENOTFOUND, UND_ERR_CONNECT_TIMEOUT, etc.) |
null |
-32011 |
Upstream route ran and returned a non-2xx. Read data.upstreamBody — that's the route's actual response (the canonical answer). |
e.g. 422, 404, 500 |
null |
Parsed JSON (or raw text on parse failure). Capped at 8 KB; data.truncated === true if hit. |
Both codes also carry data.hint (per-tool one-liner) and
data.nextActions (operator-side follow-up steps). Example:
{
"jsonrpc": "2.0",
"id": "12",
"error": {
"code": -32011,
"message": "Upstream returned 422",
"data": {
"tool": "quotes.submit",
"url": "http://localhost:4001/api/v1/quotes/abc/submit",
"method": "POST",
"status": 422,
"cause": null,
"causeMessage": null,
"upstreamBody": { "error": "2 of 5 lines have selling_price = 0", "code": "unpriced_items", "offendingItemIds": ["abc", "def"] },
"truncated": false,
"hint": "Submission can fail with 422 (unpriced_items, invalid_status) or 404 (quote_not_found). All deterministic — do NOT retry.",
"nextActions": [
"Read upstreamBody.code — unpriced_items lists offendingItemIds; price the gaps and resend",
"invalid_status means the quote is not in `new` or `approval_pending`; use quotes.get to inspect",
"quote_not_found means the id is wrong; re-resolve via quotes.list"
]
}
}
}
Decision rules:
-32010→ the upstream is unreachable. Don't retry blindly; readdata.causeto decide:ECONNREFUSED→ dev server is not running on the expected port (default:4001).ENOTFOUND→ DNS / hostname resolution failed.UND_ERR_SOCKET/ECONNRESET→ upstream crashed mid-response; check server logs.UND_ERR_CONNECT_TIMEOUT/ETIMEDOUT→ upstream is slow / overloaded; ok to retry once after a short delay.
-32011→ the upstream ran and rejected the input. Readdata.upstreamBodyfirst — that's the route's actual response (often the typed{ code: "unpriced_items", offendingItemIds: [...] }shape). Forward it to the LLM verbatim. Don't retry; fix the input and resend.- Code
-32001is reserved for auth failures (unrelated). If you see-32001, theAGENT_API_KEYis missing or rotated — flag to the operator; do not retry.
Started on 2026-08-15 as part of the Phase I gap-fill
(FEATURE-REQUESTS.md). Currently active for quotes.submit,
quotes.create, quotes.update, quotes.send_email,
quotes.partial_approve, quotes.split, quotes.ingest_from_email,
quotes.get_pdf, quotes.re_price, quotes.list_attachments,
orders.list_attachments, orders.create, orders.split,
orders.add_attachment, bulk_update_quote_items. Other write tools
still surface the legacy -32000 fetch failed and will be migrated
in a follow-up.
PDF lifecycle
Quotes render a PDF on three triggers:
quotes.send_email— auto-renders before the outbound SMTP send. The rendered file becomes the email's primary attachment.- Operator "Regenerate PDF" click — calls
quotes.update({ regenerate_pdf: true })which re-renders against the currentquote_itemsshape (used after pricing edits). quotes.get_pdfMCP tool — auto-renders if the quote has no PDF yet, otherwise returns the existing one. Passregenerate: trueto force a fresh render.
The REST GET /api/v1/quotes/[id]/pdf?attachmentId=<id> endpoint is
not a render path — it proxies an already-rendered attachment
file by id via PB's /api/files/... URL. The MCP
quotes.get_pdf POST is the auto-render path; the REST GET is
the proxy path. Use the right one for the job.
Phase H orders-side surface (added 2026-08-16)
Nine new orders-side MCP tools + one order_items tool shipped as
one mega-PR. The agent's existing recipes continue to work; new
ones for the lifecycle gaps below.
orders.list filters
The orders.list tool now accepts four new query params (all
optional, all compose with &&):
customer— 15-char PB id OR a name (case-insensitive substring match againstcustomers.customer). The MCP dispatcher resolves the name to an id internally before applying the filter.createdAfter—YYYY-MM-DD. Filtersorders.created >= <date> 00:00:00Z.createdBefore—YYYY-MM-DD. Filtersorders.created < <date> 00:00:00Z.status— a tab-group name (pending | processed | collection_pool | assigned | delivered | invoiced | cancelled). Resolves viagetOrderIdsForTabGroup: first fetches the order ids with at least one item in that group, then filters the orders query byid IN (...). Returns{ items: [], totalItems: 0 }if the status set is empty.
When the operator asks "all of David's orders last week" → pass
customer: "David", createdAfter: "<7d ago>", createdBefore: "<today>".
Don't fetch-then-filter client-side; the server handles the
id IN (...) rewrite.
order_items.create strict-pending gate
New tool order_items.create({order_id, items: [{description, quantity, cost_price?, selling_price?, markup?, sku?, unit?, supplier?, notes?}]}) adds one or more lines to an EXISTING order.
It refuses with 409 order_locked if any existing item on the
order is past item_status === "pending" — i.e. has a PO in flight
or has been assigned to a driver. The error response carries
offendingItemIds[] so the agent can surface them.
To add lines to a locked order, use orders.split first to create
a continuation order (which starts in pending), then call
order_items.create against the continuation. The split preserves
the audit trail.
selling_price is auto-derived from cost_price × (1 + markup/100)
when omitted (same deriveSellingPriceIfOmitted helper used by
quote_items.create / quote_items.update).
orders.mark_invoiced / orders.mark_delivered
Both are header-level gating functions that touch items too.
orders.mark_invoiced({id})— refuses 409uninvoiced_itemswithoffendingItemIds[]if any line item hasitem_status !== "invoiced". Idempotent: re-calling on a fully-invoiced order returns{statusChanged: false}without a PB write.orders.mark_delivered({id})— flips every eligible item todelivered(eligible:delivered | ready_delivery | collected | picking | stock_verified | ready_invoice | invoiced | stock_booked | with_driver | checked_in | completed) and stampsdelivered_at+delivered_date. Items inpending/processed/cancelledare reported indata.itemsFailed. Does NOT fliporder.invoiced— operator must explicitly callmark_invoiced.
orders.update / orders.add_note
orders.update({id, patch: {<whitelisted fields>}}) patches
header fields. Whitelist: notes, contact_name, description, rep, email_cc, email_bcc, quote_reference_text, invoiced, quote_match_method. Anything else returns 400 forbidden_field.
orders.add_note({id, note}) is a thin wrapper that REPLACES
notes with the new value (matches the quotes.update shape).
The order_items.notes JSON-array audit feed is untouched —
that's a different field on a different collection.
orders.stats / orders.daily_summary
orders.stats({from, to, groupBy})— aggregates{count, sellingPrice, costPrice}across the date window. Three group keys:day,customer,supplier. Defaults to the last 30 days iffrom/toomitted. Capped at 10k items summed to bound runtime. ThesuppliergroupBy is preserved for forward compatibility but documents "(no order-level supplier)" — PB'sorderscollection has no top-level supplier relation; the bucket key is currently meaningless until the relation lands.orders.daily_summary({date})— single-call daily snapshot (placed count, delivered count, pending count, top 3 customers by count). Use for the operator's "what did we do today?" landing question.
Whitelist consolidation
The canonical ORDER_ITEM_PATCH_FIELDS list now lives in
src/lib/server/order-items-service.ts as
ORDER_ITEM_PATCHABLE_FIELDS. Both the REST PATCH handler
(/api/v1/order-items/[id]) and the MCP order_items.update
dispatcher import from it. The REST handler keeps a narrower
POPOVER_PATCHABLE_FIELDS (intentionally) because the operator
popover doesn't need lifecycle/invoice fields — if you set
invoiced via PATCH, expect a 400.