12 KiB
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 <BASE>/api/mcp
<BASE> is the URL the operator runs WFDASH at:
- Local dev:
http://localhost:4000(or:3100if 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 schematools/call— invoke a tool by name witharguments
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):
{
"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
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "orders.list",
"arguments": { "page": 1, "perPage": 25, "search": "SO-3" }
}
}
Success returns:
{
"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.
- Respect field whitelists.
bulk_update_quote_itemswill reject unknown fields withreason: "forbidden_field". Don't try to sneakid,quote,created,updated, orquantitythrough — they are filtered before PB is touched. - Don't overwrite hand-priced rows silently. Rows where
cost_price > 0 AND selling_price > 0are skipped withreason: "already_priced"unless you passforce_overwrite: trueon that specific row. - Stamp your work. When you change pricing fields, include
pricing_notesin the patch. The tool does not auto-inject an audit string. - Use the right upload route.
attachments.upload_for_quote(MCP) → quote-level attachment/api/v1/quotes/items/{id}/attachments/upload(REST) → quote-item attachment withsource: "manual"/api/v1/sprint/items/{id}/attachments/upload(REST) → pricing proof withsource: "operator_proof"or"agent_proof"
- Don't migrate
suppliers.coordinates. It is the legacy"lat, lng"text field.supplier-map.tsparses that format. - 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.
- 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.
- Respect the per-batch cap.
bulk_update_quote_itemsis capped at 100 rows. Page larger batches. - Use
dry_run: trueon unfamiliar batches. The shape of the response is identical; onlystatuschanges 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_itemsare not a batch failure. Other rows still apply. Read the per-rowreasonand decide whether to retry. - Per-row failures in
stock_purchase_items.bulk_updatebehave the same way — one row's failure never affects another. Samereasonpattern:forbidden_field,already_priced,pb_error. mark_collectedauto-flip — the service inspects every line on the parent PO after the write. If every line has `quantity_received= quantity
, the PO header flips fromsenttoreceivedin the same call. The response carriesheader_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_emailis 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_idis optional. A stablesynth-…@wfdash.localid is derived from the email identity when missing. Still pass it when you have it — that's what makes retries idempotent.in_reply_tois supported on both tools. Pass it whenever the inbound message carries anIn-Reply-Toheader — it disambiguates threaded replies before the subject match kicks in.company_hintis supported oncreate_from_email. Free-text, case-insensitive. Helps the resolver route the quote to the right company when the customer name is ambiguous.body_htmlis supported on both tools, alongsidebody_plain. Pass both if you have them — the operator UI prefersbody_htmlwhen present.attachmentsoncreate_from_emailis deprecated. It is accepted for back-compat but ignored. Useattachments.upload_for_quoteper file after the create call returns the new quote id.attachments.upload_for_quoteis capped at 25 MB per file (raw base64-decoded bytes). Larger files must be split or rejected at the source.extracted_items[].notesis supported. Use it for line-specific context (e.g."see attached sketch page 2") that doesn't belong in the description.
// 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": "<p>Need pricing for one magnetic drill.</p>",
"message_id": "<msg-abc@mail.acme.co.za>",
"in_reply_to": "<prior-msg@mail.acme.co.za>",
"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": "<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": "<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.