# 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.