# WFDASH Agent Instructions
You are an external automation agent connecting to a running instance
of the **WFDASH** business CRM. This document tells you how to
connect, what you can do, and what you must not do.
The full reference for every endpoint and tool is in
`docs/api-mcp-layer.md`. Read it before doing anything non-trivial.
---
## 1. Connecting
WFDASH exposes a JSON-RPC MCP endpoint:
```
POST /api/mcp
```
`` is the URL the operator runs WFDASH at:
- Local dev: `http://localhost:4000` (or `:3100` if the operator
pointed Playwright there)
- Staging / production: the public host they tell you
Every request must include the agent bearer token:
```
Authorization: Bearer 123456
```
The token is shared with you out-of-band. **Do not log it. Do not
write it to disk.** Treat it like any other long-lived secret. If you
suspect it leaked, stop and ask the operator to rotate it.
The MCP endpoint accepts only two methods:
- `tools/list` — discover every tool and its input schema
- `tools/call` — invoke a tool by name with `arguments`
There is also a REST surface at `/api/v1/*` for direct calls. Prefer
the MCP tools when one matches — they encode the same business rules
and give you safer error envelopes.
---
## 2. Discovering tools
Always start by listing tools on first contact (and again when the
operator says tools have changed):
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}
```
The response includes every tool's `name`, `description`, and
`inputSchema`. Do **not** hard-code schemas from this document alone
— they drift. Trust `tools/list`.
---
## 3. Calling a tool
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "orders.list",
"arguments": { "page": 1, "perPage": 25, "search": "SO-3" }
}
}
```
Success returns:
```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "{...JSON string...}" }]
}
}
```
Failures (auth, validation, upstream PB error) come back as either
HTTP non-2xx **or** a JSON-RPC `error` object — see "Failure modes"
below.
---
## 4. What you can do
You have access to five families of tools. Always pick the **most
specific** tool that fits the task.
### 4.1 Quote email pipeline (read + write)
| Tool | Use it for |
| --- | --- |
| `quotes.create_from_email` | Ingest a parsed inbound RFQ email into a new quote (idempotent on `message_id`). |
| `quotes.append_reply` | Append an inbound reply to the right quote (`In-Reply-To` then subject fallback). |
| `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. Call **per file** after `quotes.create_from_email` returns the new quote id — never base64-embed in the create payload (blows the 1 MB request limit). |
| `bulk_update_quote_items` | Patch up to 100 quote items in one round-trip. Use `dry_run: true` first when unsure. |
### 4.2 Quotes (read + write from your side)
| Tool | Use it for |
| --- | --- |
| `orders.create_from_quote` | Turn an approved quote into an order + items. |
### 4.3 Order workflow
| Tool | Use it for |
| --- | --- |
| `orders.list` | List orders by `order_number` substring. |
| `collection_pool.list` | Inspect what drivers need to collect. |
| `order_items.update_status` | Move an item through the operational pipeline. |
### 4.4 Stock purchase + PO attachments
| Tool | Use it for |
| --- | --- |
| `purchase_orders.add_attachment` | Attach a supplier quote PDF or proof-of-delivery to a PO (base64, ≤25 MB). |
| `stock_purchases.add_attachment` | Attach a supplier invoice, packing slip, or damaged-stock photo to a stock purchase (base64, ≤25 MB). Appends to `additional_attachments`; does NOT touch the versioned-PDF `documents` ledger. |
| `stock_purchase_items.update` | Patch whitelisted pricing + catalogue fields on a single stock_purchase_item: `cost_price`, `sku`, `description`, `notes`, `unit`, `supplier_sku`, `quantity`. Hand-edit guard: `cost_price > 0` already on the line requires `force_overwrite: true`. |
| `stock_purchase_items.bulk_update` | Batch patch stock_purchase_items (≤100 rows). Same whitelist + hand-edit guard as `.update`. Per-row independence — one row's failure never affects another. `dry_run: true` returns the same shape with `status: "dry_run"` per row. |
| `stock_purchase_items.assign_driver` | Bind a stock_purchase_item to a driver. Transitions line status `null → assigned_to_driver` or `collection_pool → assigned_to_driver`. Wrong_status (409) when the line is already past the assignment step. |
| `stock_purchase_items.mark_collected` | Mark a line as physically delivered. Status → `collected`, bumps `quantity_received`. Does NOT book into stock — that's `stock_purchases.receive`. Header auto-flips to `received` when every line on the PO has `quantity_received >= quantity`. |
| `stock_items.update` | Patch catalogue fields on a master stock_item: `sku`, `description`, `notes`, `unit`, `selling_price`, `markup`. For inventory qty use `stock_items.adjust`. Returns `{ok: true, item, previous}` on success or `{ok: false, reason, message, field?}` on rejection. |
| `stock_items.set_pricing` | Set `cost_price` / `selling_price` / `markup` on existing stock_items in one batch (max 100 per call). The POS till reads `selling_price` (not `markup`), so set both consistently: selling = cost × (1 + markup/100). Cost changes are audited in `stock_ledger` with the `actor`; quantities are NOT changed (use `stock_items.adjust`). Requires `actor.id`. Returns per-row `{ok, item, previous}` / `{ok: false, reason, message}`. |
### 4.5 CRM maintenance
| Tool | Use it for |
| --- | --- |
| `companies.list` / `.get` / `.create` / `.update` / `.delete` | Maintain company records. |
| `customers.list` / `.get` / `.create` / `.update` / `.delete` | Maintain customer records. `company` / `rep` accept id or name. |
| `suppliers.list` / `.get` / `.create` / `.update` / `.delete` | Maintain supplier records. |
---
## 5. Hard rules
These come from the operator and from the codebase. Treat them as
non-negotiable.
1. **Respect field whitelists.** `bulk_update_quote_items` will
reject unknown fields with `reason: "forbidden_field"`. Don't try
to sneak `id`, `quote`, `created`, `updated`, or `quantity`
through — they are filtered before PB is touched.
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`
on that specific row.
3. **Stamp your work.** When you change pricing fields, include
`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
- `/api/v1/quotes/items/{id}/attachments/upload` (REST) →
quote-item attachment with `source: "manual"`
- `/api/v1/sprint/items/{id}/attachments/upload` (REST) →
pricing proof with `source: "operator_proof"` or `"agent_proof"`
5. **Don't migrate `suppliers.coordinates`.** It is the legacy
`"lat, lng"` text field. `supplier-map.ts` parses that 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 try to recurse and delete
the blockers.
7. **One file per call.** Don't batch multiple 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.** The shape of the
response is identical; only `status` changes per row.
---
## 6. Failure modes
- **Wrong / missing bearer** → HTTP 401 on `/api/v1/*`,
JSON-RPC error envelope on `/api/mcp`.
- **Unknown tool** → JSON-RPC error: `Method not found`.
- **PB admin unavailable** → JSON-RPC error: usually a 5xx with the
underlying message.
- **Per-row validation failures** in `bulk_update_quote_items` are
**not** a batch failure. Other rows still apply. Read the per-row
`reason` and decide whether to retry.
- **Per-row failures** in `stock_purchase_items.bulk_update` behave the
same way — one row's failure never affects another. Same `reason`
pattern: `forbidden_field`, `already_priced`, `pb_error`.
- **`mark_collected` auto-flip** — the service inspects every line on
the parent PO after the write. If every line has `quantity_received
>= quantity`, the PO header flips from `sent` to `received` in the
same call. The response carries `header_flipped: true` only when this
call was the one that completed the PO.
If you ever get a 401, **stop and check the token** before retrying —
the operator needs to know if the token expired.
---
## 7. Endpoints you should NOT call directly
The full REST surface exists for the in-app UI and tests. As an
external agent, prefer MCP tools. Only drop to REST if:
- There is no MCP tool for what you need (rare; tell the operator so
they can add one), or
- You are uploading a binary attachment (MCP upload uses base64 and
is slower than the REST multipart endpoint).
Never poke PocketBase directly. Every call goes through the
`AGENT_API_KEY`-gated admin PB client, and only via these tools.
---
## 8. Worked example: ingest an inbound RFQ email
The `quotes.create_from_email` and `quotes.append_reply` tools are
lenient on the optional fields:
- `from_email` is optional. If missing, the quote is linked to a
sentinel `"Unknown Sender"` customer
(`unknown-sender@no-from.wfdash.local`) that the operator reassigns
in the quote-editing UI.
- `message_id` is optional. A stable `synth-…@wfdash.local` id is
derived from the email identity when missing. Still pass it when
you have it — that's what makes retries idempotent.
- `in_reply_to` is supported on both tools. Pass it whenever the
inbound message carries an `In-Reply-To` header — it disambiguates
threaded replies before the subject match kicks in.
- `company_hint` is supported on `create_from_email`. Free-text,
case-insensitive. Helps the resolver route the quote to the right
company when the customer name is ambiguous.
- `body_html` is supported on both tools, alongside `body_plain`.
Pass both if you have them — the operator UI prefers `body_html`
when present.
- `attachments` on `create_from_email` is **deprecated**. It is
accepted for back-compat but **ignored**. Use
`attachments.upload_for_quote` per file after the create call
returns the new quote id.
- `attachments.upload_for_quote` is capped at **25 MB per file**
(raw base64-decoded bytes). Larger files must be split or rejected
at the source.
- `extracted_items[].notes` is supported. Use it for line-specific
context (e.g. `"see attached sketch page 2"`) that doesn't belong
in the description.
```jsonc
// 1) Create the quote (and its customer/company/items).
{
"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`.
// 3) For each attachment (one 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 and approves. If they ask you to
// turn it into an order:
{
"method": "tools/call",
"params": {
"name": "orders.create_from_quote",
"arguments": { "quote_id": "" }
}
}
```
---
## 9. 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.