WFDASH-WEBAPP/docs/agents/hermes-skill.md

422 lines
21 KiB
Markdown
Raw Normal View History

# 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 under
`src/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/*` with `AGENT_API_KEY`.
- **The Hermes WebUI drops `AGENT_API_KEY` on direct REST calls.**
Every read/write MUST use `mcp__wfdash-mcp__*`. No exception.
## Critical rules (don't break these)
1. **Hand-edit guard on bulk updates.** `bulk_update_quote_items` and
`stock_purchase_items.bulk_update` skip any row with both
`cost_price > 0` and `selling_price > 0`, returning
`already_priced`. You must pass `force_overwrite: true` ONLY when
the operator explicitly asked to re-price. Default to `false`.
**`selling_price` auto-derive.** On `quote_items.update`,
`bulk_update_quote_items`, and `quote_items.create`, when the patch
contains both `cost_price` and `markup` and omits `selling_price`,
the helpers auto-derive `selling_price = cost_price * (1 + markup/100)`
(rounded to 2 decimal places). Pass `selling_price` explicitly to
override (e.g. to preserve a catalogue-stored selling price). The
bulk response's `previous_*` / `new_*` surfaces reflect the
derived values; the dry-run `would_patch` shows the derived shape.
**`quote_items.create` idempotency.** `quote_items.create` (REST +
MCP) dedupes on `(quote_id, description, sku)` within the last 60
seconds. A duplicate hit returns the existing row and sets
`idempotent: true` on 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_source` default.** Omitting `pricing_source` on
`quote_items.create` / `quote_items.update` / `bulk_update_quote_items`
sets 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.
**`unit` field quirk.** The PB `quote_items` collection does not
currently have a `unit` column. 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, `unit` round-trips in the response but is silently
dropped on write — defer unit metadata unless you've confirmed
the column is in place.
2. **Always diff before receive.** For any supplier invoice / delivery
note:
- First call `purchase_orders.diff` (read-only, returns a
`MatchReport` with a verdict).
- Then commit with `stock_purchases.match_and_receive_from_invoice`
and `confirm: true` **only if `verdict === "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 flip `confirm: true` to "force it through".
3. **`actor: { id, name }` is REQUIRED on writes that audit.** Tools
like `order_items.confirm_collected`, `stock_purchase_items.bulk_update`,
any tool that stamps `notes` — they all require the operator
identity. Look it up via `users.lookup({q:"<name or email>"});` if
the user didn't pass one. Never invent an id.
4. **State-anchored tools have state-machine constraints you can't
see in the description.** Common ones:
- `order_items.confirm_collected` → row must be in
`collection_pool` or `assigned`. Past that refuses with
`wrong_status`.
- `stock_purchase_items.mark_collected` → header is
`sent`/`sending`/`mail_failed`.
- `stock_purchases.match_and_receive_from_invoice` (mode=`"invoice"`)
→ same; use mode=`"grn"` for delivery-note only.
- `orders.split` → only items in `pending` can be moved.
- When in doubt, `orders.get({id, expand:"items"})` first to read
the actual `item_status` before you act.
**Quote `status` enum.** The PB `quotes.status` select field has
these values (full state machine):
- `new` — just created from email / template / walk-in. Use
`quotes.submit` to 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 when
`quotes.submit` succeeds. This is the entry point for the
`quotes.send_email` path (it accepts `approval_pending` in
`RESENDABLE_STATUSES`).
- `partial_approval_pending` — partially priced; some lines
still need `selling_price > 0`. Reached via the operator's
"Submit for partial approval" flow (different from
`quotes.submit` which 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 (use `quotes.partial_approve` + `quotes.split`).
- `rejected` — customer declined.
- `claimed` — an agent picked it up (via `sprint.claim` or
`quotes.update`'s `claimed_by` field).
- `problem` — something needs operator attention.
- `cancelled` — abandoned.
The `new → approval_pending` transition is the one most affected
by `quotes.submit` (added 2026-08-15). Walk-in recipes always
end with `submit` before `send_email` — calling `send_email`
directly on a `new` quote fails pre-send validation with
`code: "invalid_status"` because `new` is not in
`RESENDABLE_STATUSES`. `quotes.submit` is idempotent
(re-submitting an `approval_pending` quote returns
`statusChanged: false` and 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.
5. **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: …})`.
6. **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).
7. **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 break `po_reference` lookup (it's `trim()`'d but only on
leading/trailing, not internal).
8. **Quote number format.** `Q{yyyy}{mm}{NNN}` — e.g. `Q202608176`.
That's `Q` + 4-digit year + 2-digit month + 3-digit zero-padded
sequence. NOT `Q-YYYY-MM-NNN` with 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:
1. Call `mcp__wfdash-mcp__tools_discovery` (or assume the catalogue
you already loaded from `docs/api-mcp-layer.md` is current).
2. Match the request to a tool name. Read the tool's `description`
string — that's your "when to use it" doc.
3. If the request chains more than one tool call, re-read the
relevant recipe in `docs/agents/workflows.md` first.
4. Before any write: read the current state with a `*.get` or
`*.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's `pb-helpers.ts` does NOT, so don't rely on search
endpoints with quotes in input — filter by exact id instead).
- **Per-row independence**: `bulk_update_*` and `confirm_collected`
return `{results: [...]}` with `confirmed[]` / `failed[]` — one
row's failure never affects another.
- **`dry_run: true`** is 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.md`
for "Requested by" — it may already be in flight.
- The tool 500s with `Failed to update record.` → the `item_status`
/ `drv_status` / `category` select 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.ts` and
the `route.ts` comments 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:
```json
{
"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;
read `data.cause` to 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.** Read
`data.upstreamBody` first — 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 `-32001`** is reserved for auth failures (unrelated). If
you see `-32001`, the `AGENT_API_KEY` is 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:
1. **`quotes.send_email`** — auto-renders before the outbound SMTP
send. The rendered file becomes the email's primary attachment.
2. **Operator "Regenerate PDF" click** — calls `quotes.update({
regenerate_pdf: true })` which re-renders against the current
`quote_items` shape (used after pricing edits).
3. **`quotes.get_pdf` MCP tool** — auto-renders if the quote has no
PDF yet, otherwise returns the existing one. Pass
`regenerate: true` to 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 against `customers.customer`). The MCP dispatcher resolves
the name to an id internally before applying the filter.
- `createdAfter` — `YYYY-MM-DD`. Filters `orders.created >= <date> 00:00:00Z`.
- `createdBefore` — `YYYY-MM-DD`. Filters `orders.created < <date> 00:00:00Z`.
- `status` — a tab-group name (`pending | processed | collection_pool |
assigned | delivered | invoiced | cancelled`). Resolves via
`getOrderIdsForTabGroup`: first fetches the order ids with at
least one item in that group, then filters the orders query by
`id 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 409 `uninvoiced_items`
with `offendingItemIds[]` if any line item has `item_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 to
`delivered` (eligible: `delivered | ready_delivery | collected |
picking | stock_verified | ready_invoice | invoiced |
stock_booked | with_driver | checked_in | completed`) and
stamps `delivered_at` + `delivered_date`. Items in
`pending`/`processed`/`cancelled` are reported in
`data.itemsFailed`. Does NOT flip `order.invoiced` — operator
must explicitly call `mark_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 if `from`/`to` omitted. Capped at 10k items summed to
bound runtime. The `supplier` groupBy is preserved for forward
compatibility but documents "(no order-level supplier)" — PB's
`orders` collection 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.