M MCP Docs
Jump to section

MCP development docs

Connect an AI agent to Autohisab Business (dev) over MCP, or add a tool to it. Everything below is generated from the live servers.

Connect

1 · Connecting

MCP is the inbound direction: an AI client you already pay for — Claude, ChatGPT, Codex — reaches into this workspace and reads or edits it on your behalf. The credential is an OAuth token this platform mints for your account, so the agent can only ever see what you can see. (The outbound direction, where the workspace calls a vendor with a key you supply, is BYOK and lives under Settings → AI Providers. Different thing, same word.)

Endpoints answering today

Every row is read from the MCP registrar's own route table at the moment this page rendered. mcp/business is the canonical connection and the one both settings screens hand out.

ServerEndpointToolsVersion

One URL. 6 endpoints were retired to get here.

Every MCP verb lands on /mcp/business, whichever package implements it, so a merchant sets up one connection and authorizes it once. /mcp/cms-page-builder, /mcp/category-management, /mcp/storefront-seo, /mcp/module-entries, /mcp/platform-data, /mcp/diagnostics no longer answer.

A client still pointed at a retired URL gets an HTTP 405 — The POST method is not supported for route mcp/cms-page-builder — because the catch-all still answers GET on that path and refuses the POST the transport needs. It reads like a server fault and is not one.

No deploy can fix this for you. The retired URL lives in the client's own config file, on the machine the client runs on — nothing this platform ships can reach it. Remove the stale server and add it back against the canonical URL:

claude mcp remove autohisab_business_dev_development
claude mcp add --transport http autohisab_business_dev_development https://business-dev.autohisab.com/mcp/business

Anyone connected to a retired URL must also re-authorize once. OAuth grants are issued per resource, so an existing authorization names the old URL and cannot be carried over. Re-running the connection flow against /mcp/business does both halves.

tools/list advertises two verbs, not the whole catalogue. search_tools takes a query and returns matching tools with their full input schemas; execute_tools runs one or more of them by name. The tool document is re-sent ahead of every conversation turn, so advertising all of it cost every caller ~134,000 bytes before the merchant's question had been read. It is now ~1,400.

A verb you already hold a name for keeps working. tools/call resolves every verb this merchant is entitled to, catalogued or not, so an existing integration needs no change — only a new client needs to discover through search_tools.

What is reachable is filtered to the connected merchant's entitlements. A verb search_tools cannot find is a capability that profile does not hold, not a verb that does not exist — explain_denial names it. The version string carries a per-caller fingerprint for the same reason, so a cached capability document cannot outlive a capability change.

Payloads are versioned, verbs are not. When a stored shape changes, the same verb answers the new shape and says where it came from, so a connected client never needs a new tool name. Layout templates are the worked example: list_layout_templates returns every template as the schema 2 tree (one root frame holding one content slot), a schema 1 row lifted in memory with adapted: true, from_schema: 1; the writes take an optional schema_version and still accept schema 1 trees.

Authorization

Every web endpoint sits behind the same stack. A client discovers the OAuth server through the standard metadata documents, runs the flow, and presents a bearer token carrying the mcp:use scope — the consent boundary. A token minted for an unrelated integration is refused.

POST /mcp/business
  → the server's tools

That stack is read off the canonical route's own middleware at render time, so the numbers in it are the numbers in force.

The middleware is not per-call authorization. mcp.draft-edit is an any-profile gate; it says nothing about the profile a given tool call names. Tenancy for the profile_id in the payload is entirely the tool's job, and every tool resolves it through App\Mcp\Tenancy\McpProfileAccess.

Discovery documents

curl -s https://business-dev.autohisab.com/.well-known/oauth-protected-resource
curl -s https://business-dev.autohisab.com/.well-known/oauth-authorization-server

These are registered by Mcp::oauthRoutes() from routes/ai.php, alongside the servers themselves. If they 404 and an MCP endpoint answers 405 — The POST method is not supported, the whole route file is unregistered and the cause is the laravel/mcp dependency, not the transport. That distinction has cost a production outage before.

Claude Code

One command per endpoint. Claude Code opens the OAuth flow in a browser on first use; there is no key to paste and nothing to store.



            

The alias carries the environment, and so does the server's own name.

A client names its own servers. Local and production advertise byte-identical tool lists, so an agent holding both connections has nothing in front of it saying which storefront a publish verb is about to reach. Both halves therefore carry the environment: autohisab_business_dev_local and autohisab_business_dev_prod as the alias, and (development) as the name the server announces on connect.

This deployment is development, so the alias it hands out is autohisab_business_dev_development. It comes from ProfileThemeService::recommendedMcpServerAliasForApp() — the same call routes/ai.php registers the stdio handle under and the AI Workspace panel shows a merchant, so the three cannot drift.

Claude Code, over stdio

Each server also registers a local stdio handle for use on a checkout of this repo. Stdio has no HTTP surface and no untrusted caller, so it carries no authorization at all — never expose one to anything you would not give a shell to.



            

Where a merchant finds this in the app

  • Settings → Advanced → AI / MCP Connection (/settings/mcp-connection) — the full setup surface: endpoint, alias, scope, the profiles the token can manage, and copy-paste config for ChatGPT, Codex, Claude Code and generic clients. The URL is built from the request host, so a merchant on a custom domain is shown one that resolves for them. It sits in workspace settings rather than under the storefront because one connection reaches orders, payments, stock and shipments as well as storefront drafts; the old /storefront/settings?tab=ai address 301s here.
  • AI Workspace → Connections — a shorter panel where a merchant is already working with an assistant. Shows both directions: the MCP endpoint, and the status of the vendor keys the workspace generates with.

Reference — generated

2 · Tool catalogue

0 tools across 0 servers, read from each server's own declaration at render time. This is the catalogue search_tools searches and execute_tools runs — not what tools/list returns, which is the two catalog verbs themselves (§ 1). Nothing below is typed by hand, so a tool added, renamed, re-described or re-annotated appears here in the same deploy that ships it.

0

Servers

0

Tools

0

Read-only

0

Destructive

0

Tier R · Read

0

Tier B · Write

0

Tier C · Confirm

0

Tier per call

Tiers are read from App\Mcp\Kernel\ToolTierRegistry, which sweeps #[McpTiered] off the filesystem. The tier is not in the tool document a client receives — #[McpTiered] is not an MCP spec annotation, so this page is the only place it is visible. 0 advertised tools declare no tier and are shown with the tier the registry enforces for them instead.

Verbs that need a confirmation token first

Tier C is not "ask the human" — MCP has no user-prompt primitive that works across clients. It forces the model to look at the effect first: call the preview verb named below, then repeat the call with the confirmation_token it returned. The token is single-use, lives 300 seconds, and is bound to a hash of the arguments, so previewing a small change and applying a large one is refused as confirmation_stale.

Tier C verbGet its token fromWhat it does
No advertised tool declares McpTier::Consequential.

Verbs that mutate or destroy

Every tool carrying #[IsDestructive], hoisted out of the per-server tables. Read this list before pointing an autonomous agent at a live storefront.

ToolServerWhat it does
No tool declares #[IsDestructive].

Annotation coverage

All 0 advertised tools answer the annotation question — including the ones that answer false, which is an answer. A guard test keeps it that way.

Annotation legend

A coloured badge is a claim the tool makes; a muted Not … badge is the same claim answered false, which is a statement and not the same thing as never having answered. A tool with no badge at all answered nothing.

Read-only Destructive Idempotent Open world Not destructive Unannotated

Tier legend

The tier answers one question: what would a merchant have to do to put this back? It is a property of the EFFECT, never of the implementation. A coloured tier badge is a declaration the author made; a muted one ending — enforced is the registry's default for a tool that declared nothing, and the default is deliberately the strict end so that forgetting the attribute costs a confirmation token rather than buying a free pass.

Tier R · Read Tier B · Write Tier C · Confirm first Tier per call Tier R · Read — enforced

Reference — generated

2a · App-declared verbs

Verbs a registered app declares in its own manifest, read from the in-process app registry — never from a merchant's install rows, because this page is public and has no tenant. An app verb is reached the same way a platform verb is: through search_tools and execute_tools, never through tools/list, which lists only the two catalog verbs themselves (§ 1). The namespaced name is app.<app-id>.<tool>; the host injects profile_id itself, so an app's input schema never declares it. Before a call goes out, the platform proxies it as a signed POST to the app's own base URL (X-App-Event: mcp.tool_call, verifiable with the SDK's Webhooks\Verifier), and the app's own endpoint for the verb is deliberately not shown here — it is internal to the app, and a client never sees it either. The result comes back wrapped as untrusted: true with provenance, exactly like any other third-party content: a success reply is the platform envelope, with the app's own output only under data.result. A merchant must switch on the app's MCP surface and grant every scope a verb requires before it is offered for their profile at all. Building a verb checks only that its scopes are within the app's MANIFEST scopes; whether the merchant GRANTED them is re-checked at every call.

A remote app declares its verbs in its submission (mcp). Approval refuses a verb, naming it, that fails the SDK's McpTool shape or the host bounds, needs a scope the submission does not request, has an endpoint that does not resolve to public addresses, or fails ONE conformance probe. The probe is the normal call body with "profile_id": 0 and "probe": true, plus the header X-App-Probe: 1, signed with a secret minted for that one call — never a merchant profile or install. It passes on an answer inside the timeout, under the byte cap, with a JSON body and a status below 500.

Verb App Scopes Input properties Description
No registered app declares MCP verbs yet.

Reference — generated

3 · Writes and confirmation

A new capability is a declaration, not a tool. #[McpWritable] goes on a small action class over a service the merchant's own screens already write through, and the action becomes reachable through perform_action and describable through describe_action — and through nothing else. 81 declarations are performable on this deployment, across 20 capabilities, and they add two entries to the tool list between them.

That flatness is the whole point. Hand-writing a tool per write for inventory, coupons, parties, purchase orders, payments and attendance would cost tens of thousands of tokens of tool document before a model reads the merchant's question — past what a small model can hold. Coverage grows; the context cost does not.

81

Declarations

48

Tier B · Write

33

Tier C · Confirm

2

Generic verbs

The Tier C flow, in two calls

There is no separate preview verb: a second one would double the write floor's cost in the capability document for nothing. dry_run is a flag on the same call.

perform_action  { "profile_id": 1, "action": "…", "arguments": { … }, "dry_run": true }
  → data.preview             what applying these arguments would do
  → data.confirmation_token  issued ONLY when the preview succeeded

perform_action  { "profile_id": 1, "action": "…", "arguments": { … }, "confirmation_token": "…" }
  → data.performed: true
  • The token is bound to the arguments. It commits to {caller, profile, action, sha256(arguments)}, so previewing -2 and applying -200 fails. Changing any argument invalidates it. The hash is computed once, in one place, and used by both calls — a normalization written twice would eventually disagree and every apply would fail as stale for a reason nobody could see.
  • Single-use, 300 seconds. Single-use because an agent that retries must look again rather than replay a confirmation it already spent; 300 seconds because the effect a preview described stops being true.
  • A failed preview issues nothing. Handing back a token beside "this would leave stock negative" would let a caller confirm an effect the preview had already refused.
  • Every rejection is confirmation_stale, never a more specific one. A caller cannot act differently on "expired" versus "issued for another profile", and a distinguishable answer would let one probe which tokens exist.
  • For the page builder the DRAFT is the preview and publish_builder_draft is the apply. Do not bolt a second confirmation on top of a draft flow.

idempotency_key

Confirmation stops the wrong write; the idempotency key stops the right write happening twice. They are independent on purpose: a token is single-use, so an agent whose transport dropped the response cannot replay it and has no way to learn whether the write landed. Sending the same key with the same arguments returns the FIRST result and executes nothing.

  • The same key with DIFFERENT arguments is a refusal, not an overwrite. It answers conflict and writes nothing — overwriting would be the worst of the three options, because the agent believes it applied its new arguments and what actually happened was the old ones.
  • Only a SUCCESS is remembered. A refused write stays retryable under the same key once the condition that caused it has gone, instead of replaying its own refusal forever.
  • Keys are honoured for 24 hours and are namespaced by profile before they reach the store, so two merchants both retrying with retry-1 cannot read each other's result back.

Declared actions on this deployment

Swept off the filesystem by App\Mcp\Write\McpWriteRegistry at render time. Each names the EXISTING service its screen writes through, so an MCP call fires the same observers, ledger rows and alerts a human click does — a declaration pointing at raw Eloquent would be a second write path, and a second write path is how manual stock adjustments stopped firing low-stock alerts in the first place. The capability is required with no default: an ungated declaration is a fatal error when the attribute is read, and a parity test holds it equal to what the named screen demands.

add_member Tier B · Write section.membership

Add a customer to a membership tier. Free tiers add the member badge now; paid tiers start the subscription and add the badge when it is paid.

Writes through MemberEnrolment::add() · same capability as the membership.members.index screen

ArgumentTypeRequiredDescription
party_id integer yes The customer (party) to add. Must belong to this business.
tier_id integer yes The membership tier to add them to.
starts_on string — Paid tiers only: subscription start date, YYYY-MM-DD. Defaults to today.
payment string — Paid tiers only: collect (record a cash payment now) or link (leave it pending and return the payment link). one of: collect, link default: link
send_payment_link boolean — Paid tiers with payment=link only: also SEND the link to the customer's phone, by WhatsApp when the business has it connected, else by SMS (metered). The answer says whether it went out (payment_link_sent, payment_link_channel, payment_link_reason). Throttled to 1 a minute and 5 a day per member. default: false
adjust_loyalty_balance Tier C · Confirm first section.membership

Add or remove a customer's stamps or points with a reason. Preview first: it shows the balance before and after.

Writes through LedgerLoyaltyWrites::adjust() · same capability as the membership.loyalty.history screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
party_id integer yes The customer (party). Must belong to this business.
programme_id string yes The programme id from list_loyalty_programmes or get_loyalty_balance.
amount integer yes Stamps or points to add (positive) or remove (negative). Not 0; at most 10000 either way.
reason string yes Why, in the merchant's words (shown in the history). At most 200 characters.
request_key string yes One key per intended change, 8-64 letters, digits, - or _. Reuse it on a retry: the same key books once.
adjust_stock Tier C · Confirm first section.inventory

Add or remove one item's stock by hand; writes the ledger and fires low-stock alerts.

Writes through InventoryAdjustmentService::adjust() · same capability as the inventory.adjust screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
item_id integer yes Item in this profile.
quantity_delta number yes Signed change (+ adds, - removes), never the new total.
reason string — Picks the ledger entry type. one of: adjustment, correction, waste, damage, theft, used, consumption, expired default: adjustment
variant_id integer — Adjust this variant's row.
unit_cost number — Cost per unit; a new lot's cost.
notes string — Stored on the ledger row.
stock_lot_id integer — Lot to move (lot-tracked items); removal without it is FEFO. `expired` writes an expiry write-off.
lot_number string — Add into a NEW lot with this number.
expires_on string — New lot expiry, Y-m-d.
manufactured_on string — New lot made on, Y-m-d.
approve_till_variance Tier B · Write section.pos

Approve an over-tolerance till variance. Always refused through MCP: a manager approves in person with their PIN.

Writes through TillSessionService::approve() · same capability as the orders.pos screen · preview perform()

ArgumentTypeRequiredDescription
session_id integer yes The session waiting for approval.
cancel_pouch_handover Tier B · Write section.till

Take back a cash handover you declared from your own pouch, before the counter has confirmed it. Nothing has moved yet, so nothing is reversed.

Writes through TillPouchService::cancel() · same capability as the tills.pouch screen

ArgumentTypeRequiredDescription
handover_id integer yes The handover you declared (query_records, entity `till_handover`, filter status=declared).
reason string yes Why it is being taken back, kept on the handover.
cancel_subscription Tier C · Confirm first section.billing

Cancel this profile's paid plan at the end of the current period, or move it to Free then (ending "move_to_free"). Data is kept; coming back within 90 days keeps the price and loyalty.

Writes through SubscriptionManager::cancel() · same capability as the billing.subscription screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
ending string — "cancel" ends the plan at period end; "move_to_free" puts the profile on Free then. Both keep every record. one of: cancel, move_to_free default: cancel
reason string — Optional reason, one of the listed keys. Never required. one of: too_expensive, not_using, missing_feature, switching, seasonal, other
change_plan Tier C · Confirm first section.billing

Move this profile's base plan to another plan when nothing is due now. When a payment is due it answers payment_required with a checkout URL for the merchant; an agent never pays.

Writes through SubscriptionManager::applyPlanChangeQuote() · same capability as the billing.plans screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
plan_id integer yes The plan to move to. Must be a base plan listed for sale for this business type.
billing_cycle string — Monthly or yearly. Defaults to the current subscription's cycle. one of: monthly, yearly
close_till_session Tier C · Confirm first section.pos

Close a till session on the counted amounts. dry_run first previews expected, counted, variance, tolerance outcome and the sweep plan without writing.

Writes through TillSessionService::close() · same capability as the orders.pos screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
session_id integer yes The open session (query_records, entity `till_session`, status open).
counts string yes JSON object, account id => counted amount in major units, or => {"denominations":[{"face":500,"qty":2}]}. An account left out counts as zero.
reason string — Why the count differs, when it does. Some policies need one to close over tolerance.
collect_payment Tier C · Confirm first section.orders

Record money received against one order into a named account, writing the payment transaction.

Writes through PaymentService::collectPartialPayment() · same capability as the orders.manage screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
order_id integer yes The order being paid. Must belong to this profile.
account_id integer yes The account the money lands in. Must be an active account of this profile; it is never chosen for you.
amount number — How much was received. Omit to collect the whole remaining balance.
notes string — Free text stored on the payment transaction.
till_id integer — The register (query_records, entity `till`) the money is taken at, when Balance Control runs two or more registers for this account. Omitted and ambiguous: refused with till_reason `till_ambiguous` (block) or parked and flagged for verification (warn).
till_session_id integer — The open till session (query_records, entity `till_session`) the money belongs to; wins over till_id.
confirm_bank_deposit Tier C · Confirm first section.till

Close a bank deposit that was in transit: confirmed (the bank credits it) or returned (the cash goes back to the safe). This is the only verb that credits a bank account for a deposit.

Writes through TillSettlementService::confirmBankDeposit() · same capability as the tills.safe screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
deposit_id integer yes The pending deposit (query_records, entity `till_settlement`, filter kind=bank_deposit status=pending).
outcome string yes confirmed or returned. one of: confirmed, returned
credited number — For `confirmed`: how much the bank actually credited, in major units. Left out, the amount sent is used.
reason string — For `returned`: why it came back. Required.
confirm_till_go_live_opening Tier B · Write section.till.settings

Confirm the opening balance of one unattended till account (gateway clearing, platform receivable or courier receivable) so Balance Control starts tracking it.

Writes through GoLiveOpeningService::confirmOpening() · same capability as the tills.go-live screen

ArgumentTypeRequiredDescription
till_account_id integer yes The unattended till account to open (query_records, entity `till_account`).
opening_amount number yes What the gateway, platform or courier is holding for this business right now, in major units.
create_item Tier B · Write section.items.create

Add one product to the catalog, optionally in a category and tracking stock.

Writes through ItemWriteService::create() · same capability as the items.create screen · preview preview()

ArgumentTypeRequiredDescription
name string yes The product name as a customer sees it.
type string — What this item is for. `menu` is a sellable catalog product, `inventory` a stocked one, `expense` a purchase-only line. one of: menu, inventory, expense default: menu
track_inventory boolean — Count stock for this item as well as its own type. Creates the stock row that adjust_stock then moves. default: false
price number — Selling price. 0 is a legitimate price and is not the same as omitting it. default: 0
cost_price number — What the item costs the merchant. Used for margin, never shown publicly.
sku string — Merchant's own code. Generated from the name by the screen when omitted; left null here.
description string — Storefront description.
category_id integer — One category to file it under. Must belong to this profile.
is_active boolean — An inactive item is kept out of the storefront and the POS. default: true
opening_quantity number — Stock on hand at creation. Only written when the item tracks inventory.
minimum_stock number — Low-stock threshold that alerts fire against. default: 0
reorder_level number — Quantity at which a reorder is suggested. default: 0
unit_type string — What the stock is counted in, e.g. pieces, kg, litre. default: pieces
track_lots boolean — Record a lot number on every receipt of this item. default: false
track_expiry boolean — Ask for a manufacture and expiry date when stock is received. default: false
shelf_life_days integer — Whole days from manufacture to expiry, used to pre-fill expiry on receipt. Only meaningful when track_expiry is on.
image_media_id integer — Optional product photo: the id of a published image already in this profile's media library. It becomes the item's featured image, exactly as set_item_image does.
create_order Tier C · Confirm first section.pos

Create a POS sale for a customer with the items and quantities given, deducting stock and sending kitchen chits.

Writes through PosOrderPlacer::place() · same capability as the orders.pos screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
items string yes A JSON array of lines, e.g. [{"item_id":41,"quantity":10},{"name":"Chowmein Buff","quantity":20,"note":"less spicy"}]. Each line needs a quantity and either item_id or name; name must match one catalog item unambiguously or the call is refused with the candidates. variant_id picks a variant, and is required for an item that has active variants. unit_price sells the line at a typed price instead of the catalog price; it needs the `section.pos.price_edit` permission (refused `denied` without it) and is recorded as a price override. composition_choices lists the composition row ids picked for the item's "pick N" groups (each group's minimum and maximum are enforced; a row that is not one of the item's options is refused `validation_failed`); a chosen option's price delta is part of the line price. addons lists [{"option_id":7,"quantity":1}] — add-on options offered on the item or its category, quantity per unit of the line; each becomes its own child line priced and taxed by the same engine, and is cancelled with its parent. An add-on that asks for text (an engraving, a gift message) needs "text" with what the customer typed, e.g. {"option_id":9,"quantity":1,"text":"Happy birthday"}: required for that option, trimmed, control characters removed, at most the option's own length (refused `validation_failed` with the limit); it is stored as plain text and printed on the kitchen ticket and the receipt.
party_id integer — The customer this order is for. Omitted, it goes to the profile's "Walk-in Customer", exactly as the POS screen preselects it.
service_location_id integer — Seat the order at this table. Only for profiles that use restaurant tables; a table someone else is holding is refused.
outlet_id integer — Ring this order for a specific outlet. Required on a multi-outlet profile when neither a table nor the calling user resolves one — the call is refused with `input_required` and the outlet is stamped permanently, so it is never guessed.
notes string — A note kept on the order.
coupon_code string — Apply this coupon code, validated exactly as the POS validates it. The discount reduces the taxable base of the lines it covers; an invalid code is ignored, as at the POS, and the preview shows the discount that will apply.
create_party Tier B · Write section.pos

Add one customer to the CRM, the same way the POS adds a walk-in.

Writes through PosInlinePartyCreator::create() · same capability as the orders.pos.parties.store screen · preview preview()

ArgumentTypeRequiredDescription
name string yes The customer's name, as it should appear on an order.
contact_person string — Who to ask for, when the customer is a business.
phone string — Contact number, stored as given.
email string — Email address. Also how a storefront signup later claims this person's orders.
notes string — A note kept on the customer record.
address_line_1 string — Street address. Sending any address field creates the customer's default address.
address_line_2 string — Second address line.
postal_code string — Postal code.
create_purchase_order Tier C · Confirm first section.purchase-orders

Create a purchase order to a supplier for the items, quantities and costs given, optionally with batch, manufacture and expiry dates per line.

Writes through PurchaseOrderService::createPurchaseOrder() · same capability as the inventory.purchase-orders.create screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
party_id integer yes The supplier (a party of this profile).
items string yes A JSON array of lines, e.g. [{"item_id":41,"quantity":10,"unit_price":4.5,"lot_number":"B17","expires_on":"2027-01-31"}]. item_id must be a catalogue item of this profile; item_variant_id picks a variant; unit_price defaults to the item's cost price; lot_number / manufactured_on / expires_on (YYYY-MM-DD) are required when the item tracks batches or expiry.
account_id integer — The account the purchase is booked against. Defaults to the profile's default account.
outlet_id integer — The outlet this order is for. Must be one the caller may act on; required when the caller is restricted to several outlets.
order_date string — Order date, YYYY-MM-DD in the business's timezone. Defaults to today.
expected_delivery_date string — Expected delivery date, YYYY-MM-DD. Defaults to the order date; never before it.
credit_terms string — Credit terms agreed with the supplier, e.g. "Net 30".
notes string — A note kept on the purchase order.
create_shipment Tier C · Confirm first section.orders

Book a courier consignment for everything still unshipped on one order, returning the tracking id.

Writes through OrderShipmentService::createForTransaction() · same capability as the orders.manage screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
order_id integer yes The order to ship. Must belong to this profile.
courier_service_id integer — Ship with this configured courier instead of the profile's resolved default.
create_till Tier B · Write section.till.settings

Create a till (a cash counter, a delivery pouch, a safe, petty cash…) and, optionally, the accounts it controls.

Writes through TillSettingsService::createTill() · same capability as the tills.create screen

ArgumentTypeRequiredDescription
name string yes What staff call this till, e.g. "Front counter".
outlet_id integer — The outlet it stands in. Omit for a business-wide till.
kind string — What it is. Defaults to `counter`. one of: counter, pouch, safe, petty, back_office, online
is_active boolean — Defaults to true.
accounts string — Optional JSON list of accounts it controls, each {"account_id":1,"control_mode":"attended","role":"drawer","verify_method":"denomination_count","float_mode":"fixed","float_amount":2000,"surplus_destination_account_id":2}. float_amount is in major units.
create_till_account Tier B · Write section.till.settings

Put an account (cash drawer, wallet, bank, card…) under a till's control, with its count method and float.

Writes through TillSettingsService::addTillAccount() · same capability as the tills.edit screen

ArgumentTypeRequiredDescription
till_id integer yes The till.
account_id integer yes The account. It must belong to the till's outlet or to the whole business.
control_mode string yes `attended` = counted in sessions; `unattended` = verified per business day. one of: attended, unattended
role string yes What the account is to the till. one of: drawer, wallet, bank, card, safe, gateway_clearing, platform_receivable, courier_receivable
verify_method string yes How it is checked at close. one of: denomination_count, entered_total, gateway_report, settlement, remittance, none
float_mode string yes `fixed` keeps float_amount and sweeps the rest; `carry_all` keeps everything; `zero` sweeps everything. one of: fixed, carry_all, zero
float_amount number — The float kept for the next session, in major units (fixed mode).
surplus_destination_account_id integer — Where the swept surplus goes (an account of this business).
create_vendor Tier B · Write section.expenses

Add one supplier (vendor) to the CRM, the same way the expense screen adds one.

Writes through InlinePartyCreator::create() · same capability as the transactions.expenses.create screen · preview preview()

ArgumentTypeRequiredDescription
name string yes The supplier's name, as it should appear on an expense or purchase.
contact_person string yes Who to ask for at the supplier.
phone string yes Contact number, stored as given.
email string — Email address.
address string — The supplier's address, as free text.
delete_till Tier C · Confirm first section.till.settings

Delete a till that has never held a session (one with history is refused; deactivate it instead).

Writes through TillSettingsService::deleteTill() · same capability as the tills.edit screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
till_id integer yes The till to delete.
delete_till_account Tier C · Confirm first section.till.settings

Remove an account from a till's control. The account and its balance are untouched; refused while the till has an open session.

Writes through TillSettingsService::removeTillAccount() · same capability as the tills.edit screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
till_account_id integer yes The till account line to remove.
describe_loyalty_programme Tier B · Write section.membership

Read one loyalty programme with every rule the editor holds: earn and reward targets, stamps or points, expiry, caps, who can earn, notifications. Writes nothing.

Writes through LedgerLoyaltyProgrammes::find() · same capability as the membership.loyalty.index screen

ArgumentTypeRequiredDescription
programme_id string yes The programme id from list_loyalty_programmes.
force_close_till_session Tier C · Confirm first section.pos

Force-close a till session without a count (expected carries forward, nothing is swept). Needs the approve permission and a reason.

Writes through TillSessionService::forceClose() · same capability as the orders.pos screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
session_id integer yes The open (or waiting) session.
reason string yes Why it is being force-closed; kept on every account line.
get_loyalty_balance Tier B · Write section.membership

A customer's stamps and points per loyalty programme (balance, held on open bills, available, next expiry), the rewards ready now and their member card number. Writes nothing.

Writes through LoyaltyReadModel::balances() · same capability as the membership.loyalty.history screen

ArgumentTypeRequiredDescription
party_id integer yes The customer (party). Must belong to this business.
get_settlement_basis Tier B · Write section.custom-item-outlet-settlement

Read which price the business settles its outlets on: custom (the custom settlement price frozen on each sale, the default), selling or cost. Writes nothing.

Writes through SettlementBasisSetting::forProfile() · same capability as the custom-item-outlet-settlement.prices screen

No arguments.

get_subscription Tier B · Write section.billing

Read this profile's subscription: base plan (legacy or not), add-ons, status, period end, pause and cancel state, quantity, price lock, loyalty tier, next renewal amount and return window. Changes nothing.

Writes through RenewalPriceQuote::forSubscription() · same capability as the billing.subscription screen · preview read()

No arguments.

handover_till_session Tier B · Write section.pos

Hand an open till session to another team member without counting it.

Writes through TillSessionService::handover() · same capability as the orders.pos screen

ArgumentTypeRequiredDescription
session_id integer yes The open session.
new_owner_user_id integer yes The team member who takes it over.
reason string — Why, kept in the session's audit trail.
invite_staff Tier B · Write profiles.manage-team

Invite one email to log in to this profile with a role, optionally linked to an employee Party.

Writes through StaffInvitationService::inviteToProfile() · same capability as the profiles.users screen · preview preview()

ArgumentTypeRequiredDescription
email string yes The email address to invite.
role string yes The role to invite them with. Must be one the caller may already assign.
party_id integer — An employee Party to link the login to on acceptance. Must belong to this profile.
issue_refund Tier C · Confirm first section.orders

Refund money against one order, writing the refund transaction and reopening or closing the order accordingly.

Writes through OrderReturnService::refund() · same capability as the orders.manage screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
order_id integer yes The order to refund against. Must belong to this profile.
amount number yes How much money to pay back, in the profile's currency. Never more than the order's refundable ceiling.
account_id integer yes The account the money leaves. Must be an active account of this profile; it is never resolved for you.
reason string yes Why the money is going back. Stored on the refund and shown to the merchant.
lines string — Optional stock leg (P8): a JSON array of the order lines that came back, each {"transaction_item_id": int, "quantity": number, "disposition": "restock"|"writeoff", "note": string (required for writeoff), "stock_lot_id": int (optional, restock into another sellable batch)}. Restock returns the units to the batch they were sold from; writeoff records them as waste. Omit to move money only.
list_eligible_registers Tier B · Write section.till

List the registers (tills) you may take money at in an outlet, with open/closed status, and which one a device uses. Writes nothing.

Writes through TillEligibility::eligibleFor() · same capability as the tills.index screen

ArgumentTypeRequiredDescription
outlet_id integer — The outlet the sale is rung at. Omitted: every outlet you can see, plus shared registers.
device_token string — A POS device id (query_records, entity `till_device_register`) to answer which register that device uses here.
list_loyalty_history Tier B · Write section.membership

A customer's loyalty history, newest first, one page at a time: earned, redeemed, adjusted, expired, with who and why. Writes nothing.

Writes through LedgerCounterLoyalty::history() · same capability as the membership.loyalty.history screen

ArgumentTypeRequiredDescription
party_id integer yes The customer (party). Must belong to this business.
programme_id string — Only this programme's rows. Not together with adjustments_only.
adjustments_only boolean — Only manual adjustments, across programmes. default: false
cursor string — The next_cursor of the previous page.
list_loyalty_programmes Tier B · Write section.membership

List the business's loyalty programmes (stamp cards and points), with their earn and reward rules and status. Writes nothing.

Writes through LedgerLoyaltyProgrammes::list() · same capability as the membership.loyalty.index screen

ArgumentTypeRequiredDescription
search string — Narrow by programme name.
kind string — Only stamp cards or only points programmes. one of: stamps, points
list_members Tier B · Write section.membership

List the members of a membership tier, one page at a time (name, phone, linked login, valid until). Writes nothing.

Writes through MemberDirectory::page() · same capability as the membership.members.index screen

ArgumentTypeRequiredDescription
tier_id integer yes The membership tier to list.
search string — Narrow by name or phone.
cursor string — The next_cursor of the previous page.
list_settlement_prices Tier B · Write section.custom-item-outlet-settlement

List items with an owner outlet and their custom outlet settlement prices (variants included), one page at a time, in minor units. Writes nothing.

Writes through SettlementPriceBook::page() · same capability as the custom-item-outlet-settlement.prices screen

ArgumentTypeRequiredDescription
search string — Narrow by item name or SKU.
owner_outlet_id integer — Only items owned by this outlet.
category_id integer — Only items in this category (and its children).
only_unpriced boolean — Only items (or items with variants) still missing a settlement price. default: false
cursor string — The next_cursor of the previous page.
lookup_catalog_targets Tier B · Write section.membership

Find items, categories, variants, property units, room types or rate plans by name (at most 20). Item and category rows carry the key loyalty programmes use. Writes nothing.

Writes through PlatformCatalogPort::searchTargets() · same capability as the membership.loyalty.index screen

ArgumentTypeRequiredDescription
kind string yes What to look for. one of: item, category, variant, pms_unit, pms_room_type, pms_rate_plan
search string — Part of the name. Blank lists the first names alphabetically.
loyalty_report_summary Tier B · Write section.membership

Loyalty figures for a date range (a preset or custom days), optionally narrowed by programme name, status or kind: active members, rewards unlocked and redeemed, reward cost, and outstanding liability today, per programme. Writes nothing.

Writes through LedgerLoyaltyProgrammes::reportSummary() · same capability as the membership.loyalty.reports screen

ArgumentTypeRequiredDescription
period string — A preset range, in the business's own timezone and calendar. Ignored when date_from or date_to is given. one of: this_month, last_month, last_30_days, this_year default: this_month
date_from string — First day (YYYY-MM-DD, the business's own day), inclusive. At most 366 days before date_to.
date_to string — Last day (YYYY-MM-DD, the business's own day), inclusive.
search string — Only programmes whose name contains this.
status string — Only active or only paused programmes. one of: active, paused
kind string — Only stamp cards or only points programmes. one of: stamps, points
make_production_batch Tier C · Confirm first section.inventory

Make a batch of a make-ahead item: deduct its ingredients and parts from stock and add the batch to its own stock at the rolled-up cost.

Writes through ProductionService::makeBatch() · same capability as the inventory.adjust screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
item_id integer yes The make-ahead item to produce. Must belong to this profile and have ingredients or parts.
quantity number yes How many units the batch makes, in the item's own stock unit. Greater than zero, at most three decimals.
variant_id integer — Make one variant of the item (its own recipe and stock row) instead of the item.
note string — Free text stored on the ledger rows of the batch. A sentence is generated when omitted.
manage_kot Tier C · Confirm first section.orders

Acknowledge, complete, cancel or move one kitchen ticket, with the same kitchen communication the KDS board sends.

Writes through KotService::markCompleted() · same capability as the orders.index screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
kot_id integer yes The kitchen ticket to act on. Must belong to this profile.
action string yes What to do to the ticket. `cancel` also voids the printed chit and returns its quantities to the order. `reprint_master` enqueues the consolidated master slip for the ticket's order at the order's OWN (ordering) outlet — it does not resend this ticket's own station chit. one of: acknowledge, complete, cancel, move, reprint_master
service_location_id integer — For `move`: the table this ticket belongs to now.
party_id integer — For `move`: the customer this ticket belongs to now.
open_till_session Tier B · Write section.pos

Open a till session (start the day): confirm or count each account's opening balance. Refused with the open session's id when one is already open.

Writes through TillSessionService::open() · same capability as the orders.pos screen

ArgumentTypeRequiredDescription
till_id integer yes The till (query_records, entity `till`).
owner_user_id integer — Who holds the session, on a till opened per person. Defaults to you.
openings string — Optional JSON object, account id => opening amount in major units, or => {"denominations":[{"face":500,"qty":2}]}. An account left out opens on what was carried forward.
pause_loyalty_programme Tier B · Write section.membership

Stop a loyalty programme earning. Balances stay and can still be redeemed; resume_loyalty_programme undoes it.

Writes through LoyaltyProgrammeStore::setPaused() · same capability as the membership.loyalty.index screen

ArgumentTypeRequiredDescription
programme_id string yes The programme id from list_loyalty_programmes.
pause_subscription Tier C · Confirm first section.billing

Pause this profile's paid plan for 1, 2 or 3 months, starting when the current paid period ends. No charge while paused; data is kept.

Writes through SubscriptionManager::pause() · same capability as the billing.subscription screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
months integer yes How long the pause lasts: 1, 2 or 3 months. one of: 1, 2, 3
reason string — Optional reason, one of the listed keys. Never required. one of: too_expensive, not_using, missing_feature, switching, seasonal, other
preview_plan_change Tier B · Write section.billing

Quote moving this profile's base plan to another plan: proration credit, amount due now, and the current plan's next renewal (price lock, loyalty). Changes nothing.

Writes through SubscriptionManager::buildPlanChangeQuote() · same capability as the billing.plans screen · preview preview()

ArgumentTypeRequiredDescription
plan_id integer yes The plan to move to. Must be a base plan listed for sale for this business type; get_subscription shows the current one.
billing_cycle string — Monthly or yearly. Defaults to the current subscription's cycle. one of: monthly, yearly
profit_loss_report Tier B · Write section.reports-profit-loss

Profit & Loss for a period, read-only and identical to the Profit & Loss screen: revenue, refunds, net sales, cost of goods sold, gross profit, expenses (total, by category and by account), net profit, in the profile currency. Changes nothing.

Writes through ProfitLossService::summarize() · same capability as the reports.profit-loss screen · preview read()

ArgumentTypeRequiredDescription
period string — Named calendar window in the profile's own timezone. Ignored when date_from and date_to are both given. one of: today, yesterday, this_week, last_week, this_month, last_month, this_year, last_year default: this_month
date_from string — Inclusive start date, YYYY-MM-DD, a calendar date in the profile's timezone. Send with date_to to override period.
date_to string — Inclusive end date, YYYY-MM-DD. The whole day counts.
outlet_id integer — Restrict the figures to one outlet. Intersected with the outlets this account may see, so it can narrow the answer and never widen it.
account_id integer — Restrict the figures to documents booked to one account, as the screen's Account filter does.
top_categories integer — How many expense categories and accounts to list before folding the rest into "other" (1-50). default: 10
purchase_orders Tier B · Write section.purchase-orders

Read purchase orders (stock bought from suppliers), read-only. Without purchase_order_id: a page of purchase orders, newest first. With it: one purchase order as a document, header plus lines, payments and refunds. Changes nothing.

Writes through Transaction::scopePurchaseOrders() · same capability as the inventory.purchase-orders screen · preview read()

ArgumentTypeRequiredDescription
purchase_order_id integer — Read this one purchase order in full. Omit to list.
date_from string — Inclusive start of the order date, YYYY-MM-DD, in the profile's timezone.
date_to string — Inclusive end of the order date, YYYY-MM-DD. The whole day counts.
party_id integer — Supplier party id.
account_id integer — Account the purchase order is booked to.
outlet_id integer — One outlet. Intersected with the outlets this account may see, so it can narrow the answer and never widen it.
status string — Lifecycle status slug, e.g. pending, partially_received, received, cancelled.
limit integer — Rows per page (1-100). default: 20
cursor string — `next_cursor` from the previous page.
receive_purchase_order Tier C · Confirm first section.purchase-orders

Post a goods receipt on a purchase order: stock arrives, batches and expiry are recorded, average cost and the order status move.

Writes through PurchaseOrderService::receive() · same capability as the inventory.purchase-orders screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
purchase_order_id integer yes The purchase order to receive against. Must belong to this profile and to an outlet the caller may act on.
lines string — A JSON array of lines, e.g. [{"transaction_item_id":812,"quantity":10,"unit_cost":4.5,"lot_number":"B17","expires_on":"2027-01-31","batch_barcode":"123456"}]. transaction_item_id is the purchase order line (get_order (type purchase) lists them); unit_cost overrides the ordered price in major units; lot_number / manufactured_on / expires_on are required when the item tracks batches or expiry and the line did not already carry them; batch_barcode gives that batch its own barcode (refused if another item, variant or batch already has it); allow_over_receive:true accepts more than is pending; allow_expired:true accepts a batch that expired before it arrived. Omit lines to receive everything still pending.
received_on string — The day the goods arrived, YYYY-MM-DD in the business's timezone. Defaults to today.
supplier_invoice_number string — The supplier's invoice or delivery note number, kept on the receipt.
record_bank_deposit Tier C · Confirm first section.till

Record cash leaving a safe for a bank. The money moves to the in-transit account and the deposit stays pending until the bank confirms it; the bank account is NOT credited here.

Writes through TillSettlementService::recordBankDeposit() · same capability as the tills.safe screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
safe_account_id integer yes The safe account the cash leaves (query_records, entity `till_account`, role `safe`).
bank_account_id integer yes The bank account it is being taken to. It is credited on confirmation, not now.
amount number yes How much is being deposited, in major units.
external_ref string yes The deposit slip or bank reference. Recording the same reference twice changes nothing.
notes string — Anything a person should read beside this deposit — who is carrying it, which branch.
record_expense Tier B · Write section.expenses

Record a single-line expense against a vendor, attributed to a named outlet.

Writes through TransactionService::createTransaction() · same capability as the transactions.expenses.create screen · preview preview()

ArgumentTypeRequiredDescription
party_id integer yes The vendor this was spent with. Must belong to this profile.
account_id integer yes The account this expense will be settled from when it is paid.
item_name string yes What was bought. One expense line per call.
quantity number yes How many units.
unit_price number yes Price per unit, in the profile's currency.
outlet_id integer — The outlet this expense belongs to. Required on any profile that has outlets: it is never guessed, and a wrong or missing one cannot be repaired afterwards.
item_id integer — Link the line to a catalogue item of this profile.
category_id integer — An active expense category of this profile.
description string — One line describing the expense.
notes string — Free text stored on the expense.
transaction_date string — When it was spent, as `YYYY-MM-DD HH:MM` in the profile's own timezone. Defaults to now.
record_outlet_settlement Tier C · Confirm first section.reports-outlet-settlement

Record a settlement between two outlets (who pays whom): move the money through split account legs on both sides, or record it only. Its preview lists each side's accounts with their till, session state and balance, and what the pair still owes.

Writes through OutletSettlementService::record() · same capability as the reports.outlet-settlement screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
from_outlet_id integer — The outlet that pays. 0 (or omitted) is Business / Central.
to_outlet_id integer yes The outlet that receives.
amount number yes How much is settled, in major units. Never more than the pair still owes.
mode string — `move_cash` (default) or `record_only` (no account or till change).
pay_legs string — move_cash only: JSON list of {"account_id", "till_id"?, "amount"} the payer pays from; sums to `amount`, each within the account balance.
receive_legs string — move_cash only: JSON list of {"account_id", "till_id"?, "amount"} the receiver takes into; sums to `amount`.
settled_on string — `YYYY-MM-DD` in the profile timezone. Defaults to now; never in the future.
notes string — Cheque number, reference, anything a person should read.
basis string — Optional. The business settles on ONE basis (selling price, or the one chosen in the Custom Item Outlet Settlement app); preview returns it. Any other value is refused with `basis_mismatch`.
idempotency_key string — Any unique string for this settlement. Sending the same key with the same payload records it once; the same key with a different payload is refused (`idempotency_conflict`).
record_salary_payment Tier C · Confirm first section.payment-out

Pay an employee or contractor from a named account, writing a Payment Out.

Writes through TransactionService::createPaymentOutTransaction() · same capability as the transactions.payment-out.index screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
party_id integer yes The employee or contractor being paid.
account_id integer yes The account the money leaves.
amount number yes How much is paid. Greater than 0.
period string — Pay period as `YYYY-MM`, kept in the description.
payment_method string — Defaults from the account type. one of: cash, card, transfer, wallet, cheque, other
outlet_id integer — The outlet paying. Must be in your outlet scope.
description string — Free text.
transaction_date string — `YYYY-MM-DD HH:MM` in the profile timezone. Defaults to now.
record_settlement Tier C · Confirm first section.till

Record a gateway payout, platform payout or courier COD remittance that arrived: what was received, what was deducted as fees, and the variance against what was owed. Its preview and its not_found both carry what is currently owed to this business.

Writes through TillSettlementService::record() · same capability as the tills.settlements.index screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
account_id integer yes The clearing or receivable account the money was owed on. Name one that is not owed anything and the refusal lists every account that IS, with its amount and age.
destination_account_id integer yes The account the money actually arrived in — usually the bank.
received number yes How much arrived, in major units (what the screen takes).
external_ref string yes The payout or remittance reference from the gateway, platform or courier. Recording the same reference twice changes nothing.
fees number — What the gateway or courier deducted, in major units. Default 0.
expected number — What we believed was owed, in major units. Left out, the account balance is used.
counterparty string — Who paid — the gateway, platform or courier. Defaults to the account name.
period_start string — First business date this payout covers (YYYY-MM-DD).
period_end string — Last business date this payout covers (YYYY-MM-DD).
profile_settlement_id string — For a PLATFORM payout, the platform settlement row this closes. Left out, the oldest one still awaiting a payout is used.
notes string — Anything a person should read beside this settlement.
record_till_movement Tier B · Write section.pos

Record a pay in, pay out, drop, pickup or correction on a till account, in an open till session. A transfer between accounts is transfer_till_cash.

Writes through TillManualMovementService::record() · same capability as the orders.pos screen

ArgumentTypeRequiredDescription
till_account_id integer yes The till account the money moves on (query_records, entity `till_account`).
kind string yes pay_in, pay_out, drop, pickup or correction. To move money to another account use transfer_till_cash. one of: pay_in, pay_out, drop, pickup, correction
amount number yes How much, in major units, always positive: the kind decides the direction.
reason string yes Why — kept on the movement and read on the Z report.
idempotency_key string yes Your key for this movement. Repeating a call with the same key returns the same movement and moves no money again.
session_id integer — The open session to write into. Required for a cash-counted (attended) account.
destination_account_id integer — For a drop when the outlet has no safe: the account the cash went to. With a safe the drop lands in it and this is ignored.
category_id integer — Required for pay_out: the expense category the money was spent on (query_records, entity `category`, type expense). The pay out is recorded as a paid expense in it and reaches the P&L.
source string — Required for pay_in: where the cash came from. `account` = another of the business's accounts, such as the safe (a transfer, pass source_account_id); `other_income` = money earned that is not a sale (the P&L's Other income, never sales). one of: account, other_income
source_account_id integer — For pay_in with source `account`: the account the cash came from. It goes down by the same amount.
record_transfer Tier C · Confirm first section.accounts

Move money from one account of this profile to another, writing a leg on each account.

Writes through AccountTransferService::record() · same capability as the accounts.edit screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
from_account_id integer yes The account the money leaves.
to_account_id integer yes The account the money enters. Must differ from from_account_id.
amount number yes How much to move. Greater than 0.
description string — Why the money moved.
transaction_date string — `YYYY-MM-DD HH:MM` in the profile timezone. Defaults to now.
remove_member Tier B · Write section.membership

Remove a customer from a membership tier: the member badge ends now and a paid tier subscription is cancelled.

Writes through MemberEnrolment::remove() · same capability as the membership.members.index screen

ArgumentTypeRequiredDescription
party_id integer yes The customer (party) to remove.
tier_id integer yes The membership tier to remove them from.
report_member_card_lost Tier C · Confirm first section.membership

Replace a member's lost card: issue a new card number and stop the old one working at the till. Balances are kept. Preview first.

Writes through MemberCardDelivery::reportLost() · same capability as the membership.loyalty.history screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
party_id integer yes The customer (party). Must belong to this business.
resend_member_card Tier C · Confirm first section.membership

Send a loyalty member their member card number again by WhatsApp or SMS. Preview first: it shows where it would go.

Writes through MemberCardDelivery::send() · same capability as the membership.loyalty.history screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
party_id integer yes The customer (party). Must belong to this business.
resolve_member_code Tier B · Write section.membership

Find the customer a member code belongs to — the rotating member QR from their phone or the printed card number — on this business only. Answers the customer id and name. Writes nothing.

Writes through LedgerCounterLoyalty::resolveMemberCode() · same capability as the membership.loyalty.index screen

ArgumentTypeRequiredDescription
code string yes The member code as scanned (T…-…) or the card number as printed (MB-1234-5678).
resume_loyalty_programme Tier B · Write section.membership

Start a paused loyalty programme earning again.

Writes through LoyaltyProgrammeStore::setPaused() · same capability as the membership.loyalty.index screen

ArgumentTypeRequiredDescription
programme_id string yes The programme id from list_loyalty_programmes.
resume_subscription Tier B · Write section.billing

Undo a scheduled cancel or pause, or end the owner's own running pause now (the next period is then due; the answer carries the checkout link for the merchant). An administrator's hold is refused.

Writes through SubscriptionManager::resume() · same capability as the billing.subscription screen · preview preview()

No arguments.

return_to_supplier Tier C · Confirm first section.purchase-orders

Return received goods on a purchase order to the supplier as a debit note, taking the stock out and crediting or refunding the amount.

Writes through PurchaseReturnService::create() · same capability as the inventory.purchase-orders screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
purchase_order_id integer yes The purchase order the goods were received on. Must belong to this profile and to an outlet the caller may act on.
lines string yes A JSON array of lines, e.g. [{"transaction_item_id":812,"quantity":2,"stock_lot_id":55}]. transaction_item_id is the purchase order line; stock_lot_id names the batch the goods leave from (a batch received on this order); omit it to take the earliest-expiring batches first.
reason string yes Why the goods go back. one of: damaged_in_transit, wrong_item, short_supply, quality_rejected, expired_on_arrival, other
reason_note string — Describe the reason. Required when reason is `other`.
settlement string — How the supplier settles: `credit` lowers what is owed to them; `refund` records money received back. one of: credit, refund default: credit
refund_account_id integer — The account the refund was received into. Required when settlement is `refund`; never resolved for you.
returned_on string — The day the goods went back, YYYY-MM-DD in the business's timezone. Defaults to today.
note string — A note kept on the debit note.
sales_report Tier B · Write section.reports-sales

Sales figures for a period, split by the account payments landed in, plus a link to the interactive Sales report. Figures only unless document: true, which also renders a PDF download link.

Writes through SalesReportSummaryService::summarize() · same capability as the reports.sales screen · preview preview()

ArgumentTypeRequiredDescription
period string — Reporting window, read in the profile's own timezone. last_week / last_month are the whole previous calendar week / month (week starts on the profile's week start day). Ignored when date_from and date_to are both given. one of: today, yesterday, 7days, 30days, 6months, 1year, this_week, last_week, this_month, last_month default: 30days
date_from string — Inclusive start date, YYYY-MM-DD, as a calendar date in the profile's timezone. Send with date_to to override period.
date_to string — Inclusive end date, YYYY-MM-DD. The whole day counts.
outlet_id integer — Restrict the figures to one outlet. Intersected with the outlets this account may see, so it can narrow the answer and never widen it.
account_id integer — Restrict the figures to sales booked to one account.
document boolean — Also render and store a PDF and return its download link. Only when the merchant asks for a file; a question answered in chat needs the figures alone. default: false
save_loyalty_programme Tier C · Confirm first section.membership

Create a loyalty programme (stamp card or points), or change one. Preview first: it validates like the editor and shows what would be saved.

Writes through LoyaltyProgrammeStore::save() · same capability as the membership.loyalty.index screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
programme_id string — Update this programme (from list_loyalty_programmes). Omit to create one. On update, only the arguments you send change.
name string — The programme name customers see. At most 80 characters.
kind string — stamps (a stamp card) or points (points per amount spent). one of: stamps, points
earn_target string — Stamps only: what earns a stamp, as the key lookup_catalog_targets returns (item:<id> or category:<id>).
stamps_needed integer — Stamps only: stamps for one reward, 2-50.
reward_target string — The reward, as a key from lookup_catalog_targets (item:<id> or category:<id>).
points_per_amount integer — Points only: points earned per `amount` spent.
amount string — Points only: the spend that earns points_per_amount, in the business currency, e.g. "100" or "2.50".
reward_cap string — Optional: the reward is worth at most this much, in the business currency, e.g. "280" or "2.500"; a dearer item is given with this much off. "0" removes the cap.
reward_threshold integer — Points only: points for one reward.
tier_multipliers string — Points only: membership tier multipliers as "Tier:multiplier" pairs, comma separated, e.g. "Gold:2, Silver:1". Send "" to clear.
expiry_months integer — Stamps or points expire this many months after they are earned, 1-60.
channels string — Where rewards can be used, comma separated: pos, online.
who_can_earn string — Everyone, or only members of earn_tiers. one of: all, tiers
earn_tiers string — With who_can_earn tiers: tier names, comma separated.
max_per_bill integer — At most this many stamps/rewards per bill, 1-99. 0 removes the cap.
max_per_day integer — At most this many per customer per day, 1-999. 0 removes the cap.
redeem_needs_manager boolean — A manager must approve every redemption at the till.
notify string — What the customer is told, comma separated: earned, reward_ready, expiring. Send "" for nothing.
expiry_notice_days integer — With notify expiring: remind customers this many days before their stamps or points expire, 1-60 (default 7).
active boolean — False saves it paused.
set_category_rules Tier B · Write section.categories

Replace the matching rules of a smart category, changing which products it lists.

Writes through SmartCategoryService::normalizeRules() · same capability as the categories.edit screen · preview preview()

ArgumentTypeRequiredDescription
category_id integer yes The smart category whose rules are being replaced. Must belong to this profile.
conditions string yes A JSON array of conditions, e.g. [{"field":"tag","operator":"equals","value":"featured"},{"field":"price","operator":"less_than","value":500}]. field is one of title, tag, price, type, identifier, sku. operator is one of equals, not_equals, contains, starts_with for text, and equals, not_equals, greater_than, less_than for price. A condition the service cannot read is dropped, and an empty result is refused rather than saved.
match_type string — `all` requires every condition, `any` requires one. one of: all, any default: all
set_item_barcode Tier B · Write section.items.edit

Give a product or one of its variants its own barcode (EAN/UPC/in-store/QR), or go back to a generated in-store code.

Writes through ItemScanCodeService::assignBarcodeToItem() · same capability as the items.edit screen

ArgumentTypeRequiredDescription
item_id integer yes The product. Must belong to this profile.
variant_id integer — One variant of that product, when the barcode belongs to the variant. Omit for the product itself.
barcode string — The code exactly as printed or scanned: a 4–6 digit in-store code, a GS1 number (EAN-13, UPC-A, UPC-E, EAN-8, ITF-14 — the check digit must match; it is never corrected) or free text / a QR payload up to 128 characters. Always a JSON string, never a number. Omit or send an empty string to go back to a generated in-store code.
set_item_image Tier B · Write section.items.edit

Set a catalog item's product photo (featured image) or add a gallery image, from an image already in the media library.

Writes through ItemMediaService::attachLibraryImage() · same capability as the items.edit screen · preview preview()

ArgumentTypeRequiredDescription
item_id integer yes The item (product) to give an image. Must belong to this profile. Items with variants are fine: the image goes on the parent item.
media_id integer yes An image in this profile's media library — e.g. the id returned by an upload. Must be a published image of this profile.
featured boolean — true makes it the featured/primary product image and un-features the others; false appends it to the gallery. An item with no images gets it as featured either way. default: true
alt_text string — Alt text describing the photo for screen readers and search. Omit to keep the library image's own alt text.
set_lot_barcode Tier B · Write section.inventory

Give a stock batch its own barcode, change it, or remove it, so it can be found by a POS scan.

Writes through ItemScanCodeService::assignBarcodeToLot() · same capability as the inventory.adjust screen

ArgumentTypeRequiredDescription
stock_lot_id integer yes The batch (stock lot). Must belong to this profile; get_order (type purchase) or query_records (entity stock_lot) finds it.
barcode string — The code exactly as printed or scanned: a 4–6 digit in-store code, a GS1 number (EAN-13, UPC-A, UPC-E, EAN-8, ITF-14 — the check digit must match; it is never corrected) or free text / a QR payload up to 128 characters. Always a JSON string, never a number. Omit or send an empty string to REMOVE the batch's current barcode instead of changing it.
set_lot_price Tier C · Confirm first section.inventory

Set the price one stock batch sells at in store: a percent markdown, a fixed price, or back to catalog. Never above catalog; not shown on the storefront.

Writes through StockLotPricingService::setPrice() · same capability as the inventory.lots.show screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
stock_lot_id integer yes The batch (stock lot). Must belong to this profile.
mode string yes `markdown` needs discount_percent; `fixed` needs price_minor; `catalog` clears the batch price. one of: catalog, markdown, fixed
discount_percent number — Percent off catalog, above 0 and below 100, two decimals at most.
price_minor integer — The batch unit price in minor units (paisa), below catalog.
receipt_label string — Printed under the line on the receipt; 120 characters at most.
active_from string — Optional start, profile-local `YYYY-MM-DD` or `YYYY-MM-DD HH:MM`.
active_to string — Optional end, same format; a bare date means the end of that day.
set_lot_status Tier C · Confirm first section.inventory

Quarantine a stock batch (stop it being sold) or release a quarantined batch back to sale. Never changes quantity; never releases an expired batch.

Writes through StockLotStatusService::setStatus() · same capability as the inventory.lots.show screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
stock_lot_id integer yes The batch (stock lot). Must belong to this profile; get_order (type purchase) lists the batches a PO received.
status string yes `quarantined` holds the batch back from sale; `active` releases it. one of: quarantined, active
note string — Why, kept on the batch (e.g. "supplier recall 2026-09").
set_settlement_basis Tier B · Write section.custom-item-outlet-settlement

Choose which price the business settles its outlets on: custom (the custom settlement price frozen on each sale), selling or cost. Changes how the settlement report values lines; recorded settlements are untouched.

Writes through SettlementBasisSetting::set() · same capability as the custom-item-outlet-settlement.prices screen

ArgumentTypeRequiredDescription
basis string yes One of: custom, selling, cost.
set_settlement_prices Tier B · Write section.custom-item-outlet-settlement

Set, change or clear the custom settlement price other outlets pay an item's owner outlet. Applies to new sales only; blank clears (the item then settles at its selling price).

Writes through SettlementPriceBook::apply() · same capability as the custom-item-outlet-settlement.prices screen

ArgumentTypeRequiredDescription
prices string yes JSON array of {"item_id":int, "variant_id":int|null, "price":"major units"|null}. A null or "" price clears it. Items with variants are priced per variant. At most 500 per call, e.g. [{"item_id":41,"price":"200"},{"item_id":42,"variant_id":7,"price":null}].
set_till_assignments Tier B · Write section.outlets

Set who may take money at a register (replaces the list; empty = everyone at its outlet).

Writes through TillAssignments::sync() · same capability as the settings.outlets.edit screen

ArgumentTypeRequiredDescription
till_id integer yes The register. query_records with entity `till` lists them.
user_ids string yes The people, as a JSON array or comma-separated member ids, e.g. [12,15]. Empty = no one listed (everyone at the outlet).
set_till_policy Tier B · Write section.till.settings

Set till policy keys (blind count, tolerance, what happens over tolerance, opening count, cut-over…) for the whole business or one outlet. dry_run shows the resolved policy.

Writes through TillSettingsService::writePolicyLayer() · same capability as the tills.policy screen · preview preview()

ArgumentTypeRequiredDescription
settings string yes JSON object of policy keys, e.g. {"count_mode":"blind","over_tolerance":"manager_pin","variance_tolerance":{"drawer":50}}. Keys: enabled, session_owner (till|person), enforcement (block|warn), count_mode (blind|open), denominations_required, variance_tolerance (role => major units), over_tolerance (record|reason|manager_pin), opening_count (trust_carry|recount), drop_prompt_above (major units), verify_note_threshold_minor (major units of the book currency, stored as minor; above it a verified till period needs a note; null = never), pouch_handover_after_minutes (0 = never), stale_after_hours, business_day_cutover (HH:MM), require_close_before_clock_out, pay_out_reasons, hidden_denominations. On an outlet, null removes the override.
outlet_id integer — Write this outlet's override layer. Omit for the business default.
settle_pouch_to_counter Tier B · Write section.till

Confirm the cash a rider or waiter handed from their pouch to the counter, or read what a rider is still holding.

Writes through TillPouchService::confirm() · same capability as the tills.sessions.index screen

ArgumentTypeRequiredDescription
handover_id integer — The handover the rider declared (query_records, entity `till_handover`, filter status=declared).
received_amount number — What the counter actually counted, in major units. Leave it out to read the handover and the rider's outstanding cash without confirming anything.
reason string — Required when what you counted is not what the rider declared: it is kept against the rider's pouch.
rider_user_id integer — Read-only: ask what this rider is still holding, instead of naming a handover.
transfer_till_cash Tier C · Confirm first section.pos

Move money from a till account to another of the business's accounts (a bank, the safe, another till), in an open till session. Both sides land together; needs a confirmation.

Writes through TillManualMovementService::record() · same capability as the orders.pos screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
till_account_id integer yes The till account the money leaves (query_records, entity `till_account`).
destination_account_id integer yes The account the money goes to: another of this business's accounts, not the source, at an outlet you can see (query_records, entity `account`).
amount number yes How much, in major units, always positive.
reason string yes Why — kept on both sides and read on the Z report.
idempotency_key string yes Your key for this transfer. Repeating a call with the same key returns the same transfer and moves no money again.
session_id integer — The open session to write into. Required for a cash-counted (attended) account.
update_account Tier B · Write section.accounts

Change an account's name, type, account number, bank name or description, as the Edit Account screen saves them. Only the arguments given change; the balance is never changed here.

Writes through AccountDetailsService::update() · same capability as the accounts.edit screen

ArgumentTypeRequiredDescription
account_id integer yes The account (query_records, entity `account`).
name string — The account name.
type string — The account type slug, e.g. cash, bank, wallet.
account_number string — The bank account number; an empty string clears it.
bank_name string — The bank name; an empty string clears it.
description string — A note about the account; an empty string clears it.
update_item Tier B · Write section.items.edit

Change the name, price, description, category or active state of one catalog item. Product photos / featured images are set with set_item_image, not here.

Writes through ItemWriteService::update() · same capability as the items.edit screen · preview preview()

ArgumentTypeRequiredDescription
item_id integer yes The item to edit. Must belong to this profile.
name string — New product name. Omit to leave it.
price number — New selling price. Omit to leave it; 0 is a price, not an omission.
cost_price number — New cost price.
sku string — New merchant code.
description string — New storefront description.
category_id integer — Replace the item's categories with this one. Must belong to this profile. Omit to leave the existing set alone.
is_active boolean — Set the active state. An inactive item leaves the storefront and the POS.
minimum_stock number — New low-stock threshold. Only for an item that already tracks inventory.
reorder_level number — New reorder level. Only for an item that already tracks inventory.
track_lots boolean — Record a lot number on every receipt of this item.
track_expiry boolean — Ask for a manufacture and expiry date when stock is received.
shelf_life_days integer — Whole days from manufacture to expiry, used to pre-fill expiry on receipt. Only meaningful when track_expiry is on.
addon_groups string — Replace the item's OWN add-on groups (category groups are untouched) with this JSON array, e.g. [{"id":3,"name":"Personalise","min_select":0,"max_select":1,"options":[{"id":7,"name":"Engraving","price_minor":500,"input_kind":"text","max_length":30}]}]. Send every group to keep (with its id); a group or option left out is removed. An option is a price-only extra (name + price_minor ≥ 0) or another item (related_item_id). input_kind is "choice" (picked, the default) or "text" (picked and the customer types text, e.g. an engraving or gift message) and then max_length, 1 to 500 characters, is required. Refused `validation_failed` with the same messages the Items form shows. Omit to leave the add-ons alone.
category_addon_groups string — Create or update CATEGORY add-on groups (shared by every item in the listed categories, now and later) with this JSON array, e.g. [{"id":12,"name":"Gift extras","category_ids":[4,9],"min_select":0,"max_select":2,"options":[{"name":"Gift wrap","price_minor":150}]}]. category_ids is required (this profile's plain categories, one or many); send a group's id to update it. Groups not listed are left alone. Same options and messages as addon_groups. Omit to leave category groups alone.
kit_return_mode string — What a sales return of this kit puts back in stock: `break` puts each item back on its own (the default), `whole` puts the packed kit back as one unit. `whole` is accepted only for a make-ahead (assembled-ahead) kit; otherwise the write is refused with the same message the Items form shows. one of: break, whole
update_till Tier B · Write section.till.settings

Change a till's name, outlet, kind or active flag. Only the arguments given change.

Writes through TillSettingsService::updateTill() · same capability as the tills.edit screen

ArgumentTypeRequiredDescription
till_id integer yes The till. query_records with entity `till` lists them.
name string — New name.
outlet_id integer — Move it to this outlet (0 = business-wide).
kind string — What it is. one of: counter, pouch, safe, petty, back_office, online
is_active boolean — false retires the till without losing its history.
update_till_account Tier B · Write section.till.settings

Change how a till controls one account (count method, float, surplus destination, active). Only the arguments given change.

Writes through TillSettingsService::updateTillAccount() · same capability as the tills.edit screen

ArgumentTypeRequiredDescription
till_account_id integer yes The till account line (query_records, entity `till_account`).
account_id integer — Point the line at another account of the till's outlet or the whole business.
control_mode string — attended or unattended. one of: attended, unattended
role string — What the account is to the till. one of: drawer, wallet, bank, card, safe, gateway_clearing, platform_receivable, courier_receivable
verify_method string — How it is checked at close. one of: denomination_count, entered_total, gateway_report, settlement, remittance, none
float_mode string — fixed, carry_all or zero. one of: fixed, carry_all, zero
float_amount number — The float, in major units.
surplus_destination_account_id integer — Where the swept surplus goes.
verify_till_exact_matches Tier C · Confirm first section.till

Verify, in till order, every waiting gateway / delivery-platform / courier till period at exactly its expected figure (no statement). Periods with refunds, corrections or manual movements are left for a manual verify. dry_run shows the count and totals first.

Writes through TillSessionService::verifyExactMatches() · same capability as the tills.sessions.index screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
till_ids string — Optional JSON list of till ids to limit the run to, e.g. [3, 7]. Left out = every till you can reach.
verify_till_period Tier C · Confirm first section.till

Verify a gateway, delivery-platform or courier till period against what actually arrived, splitting any provider fee into its own expense. dry_run previews expected, received, fee and difference without writing.

Writes through TillSessionService::verify() · same capability as the tills.sessions.index screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
session_id integer yes The period waiting for check (query_records, entity `till_session`, filter status=pending_verification). Periods of one till are verified oldest first.
received string yes JSON object, account id => what actually arrived for that account by the end of the period, in major units, e.g. {"42": 48250}. Every account on the period needs a figure.
fees string — JSON object, account id => the fee the provider kept, in major units. Posts to "Payment processing fees"; only the rest of the shortfall posts to "Payment processor difference". Each fee is more than zero and no more than that account's shortfall.
reason string — Why the figure differs. Required when the unexplained difference is above the business's note threshold.
write_off_settlement_order Tier C · Confirm first section.reports-outlet-settlement

Write off a credit (khata) sale whose balance will never be collected, for outlet settlement: records the closure on the settlement ledger with the reason. Nothing collected is owed to the item owner, so no amount changes; the report explains the order.

Writes through SettlementWriteOff::writeOff() · same capability as the reports.outlet-settlement screen · preview preview()

Needs a confirmation_token. Call perform_action with these arguments and dry_run: true, read the preview, then repeat with the token it returns.

ArgumentTypeRequiredDescription
transaction_id integer yes The sale (order) to write off. It must still have an open balance.
reason string yes Why the balance will never be collected (customer unreachable, disputed, agreed loss).

Bespoke tool classes have not gone away

A declaration is the default, not the only option. A named verb still earns its place when the NAME is what lets a model choose correctly — publish_builder_draft is a better thing for a model to reach for than perform_action with an action string — or when the verb needs a schema, a response shape or a preview partner that the declaration's closed argument list cannot express. Most of the 0 advertised tools are exactly that. What changed is the default: reach for a tool class when a name buys something, and for a declaration when it does not.

Reference — generated

4 · Reason codes

A small model cannot reason about prose, but it can branch on a fixed vocabulary — and only if the vocabulary never grows silently. App\Mcp\Kernel\McpReasonCode is that whole surface, deliberately closed at 78 cases. Adding one is a plan change, not a code change, and McpDiscoverabilityTest fails when a tool invents a code outside it.

Retryable means the failure was about the moment, not the request. Two codes qualify. Everything else fails identically forever, so a client that retries them is looping. The flag is derived from the code and never passed in by a tool.

CodeRetry?What the client should do
unknown_capability

UnknownCapability

No The capability string the tool acts under is not in the access-control catalog on this deployment. Nothing the caller sends fixes it — report it; it is a seeding bug (`php artisan access-control:sync`).
missing_dependencies

MissingDependencies

No The capability exists but depends on another the profile does not hold. Read `message` for the chain, and tell the merchant which plan or permission unlocks it. Do not retry.
denied

Denied

No The profile is reachable and the capability is simply not granted. Ask the merchant to grant it, or use a different profile. Do not retry.
profile_not_accessible

ProfileNotAccessible

No The `profile_id` names no profile this token can reach — either it does not exist or it belongs to someone else, and the two are deliberately indistinguishable. Pick one of the candidates in `hints`.
confirmation_required

ConfirmationRequired

No A guarded write needs an explicit acknowledgement first. Call the matching preview verb, show the merchant what it says, then resend with the `confirmation_token` it returned.
confirmation_stale

ConfirmationStale

No The token was issued for different arguments, or the underlying state moved on. Re-run the preview and confirm against the fresh token; never reuse an old one.
not_found

NotFound

No The id or name does not resolve. The failure always carries at least one hint naming a verb that lists valid values — call that rather than guessing another id.
validation_failed

ValidationFailed

No The arguments are wrong in a way the tool states. Fix the argument named in `message`; sending the same payload again fails identically.
rate_limited

RateLimited

Retryable The MCP transport throttles at 300 requests per minute. Back off and repeat the identical call — this is one of the two reasons `retryable` is true.
unavailable

Unavailable

Retryable A dependency the tool needs is momentarily down. Repeat the identical call after a pause; if it persists, surface it rather than looping.
conflict

Conflict

No Someone else changed the same record first. Re-read the current state and decide again — do not resend the write blindly.
node_locked

NodeLocked

No The node is a required element of a system page, so delete, duplicate, unbind and change-type are refused on it — for everyone, not just this caller. `message` carries the merchant-readable reason stored on the node. Move it, restyle it or edit its content instead; never retry the same write.
inside_component_instance

InsideComponentInstance

No The write reaches inside a linked component instance node, whose content belongs to the component and is re-resolved on every read. Edit the component with `update_library_component_source` (every placement changes), or detach this one instance with `detach_linked_library_component` and `node_id`. The instance's own size, position, margin and visibility stay editable. Never retry the same write.
required_element_missing

RequiredElementMissing

No The page type declares elements this template no longer contains, so the publish was refused and every missing element is named. Add them to the draft and publish again — the identical call fails identically until the draft changes.
theme_mismatch

ThemeMismatch

No A profile can hold several themes, one of them active. The write names a theme that does not own the rows it would change — a `theme_id` that is not the page's, or a draft carrying a section stamped with another theme — and there is no safe way to guess which one was meant, so it is refused with both ids named. Publish the page in its own theme, or activate the other theme first; never retryable as sent.
input_required

InputRequired

No A value the call needs is missing, and the envelope carries a `needs` array naming each field, what it is for in plain words, and the routes by which it may be obtained (`user` — ask the merchant; `generate` — write it yourself and show them). ASK which route they want; do not choose one silently. Never retryable: the identical call has nothing new to offer. Work already done is not thrown away — a tool that had generated something first hands back a handle (e.g. a generation_id) to resume from.
lot_required

LotRequired

No The item tracks lots and a purchase line named none. Ask the merchant for the lot (batch) number printed on the goods and resend; never invent one.
expiry_required

ExpiryRequired

No The item tracks expiry and a purchase line named no `expires_on`. Ask the merchant for the date on the pack and resend.
over_receive

OverReceive

No The line would receive more than the purchase order still has pending. Read get_order (type purchase) for the pending quantity; send `allow_over_receive: true` only when the supplier really delivered extra.
over_return

OverReturn

No The line would return more than was received and not yet returned. Read get_order (type purchase) for what is returnable and resend a smaller quantity.
lot_insufficient

LotInsufficient

No The named lot holds less than the line asks for. Pick another lot from get_order (type purchase), or split the quantity across lots.
lot_expired

LotExpired

No The lot is past its expiry date, and the write would make it sellable again. An expired lot is written off, never released; do not retry.
price_not_lowered

PriceNotLowered

No The batch price would be at or above the catalog price, and a batch price only ever lowers it — the engine would ignore it. Resend a deeper `discount_percent`, a lower `price_minor`, or mode `catalog`; do not retry as sent.
received_in_future

ReceivedInFuture

No The receipt's `received_on` is after today. Resend with the actual day the goods arrived.
received_before_order

ReceivedBeforeOrder

No The receipt's `received_on` is before the purchase order's own date. Resend with a date on or after the order date.
manufactured_after_received

ManufacturedAfterReceived

No A line's `manufactured_on` is after the day the stock was received. Fix the manufacture date, or the `received_on` if it was entered wrong.
expiry_not_after_manufacture

ExpiryNotAfterManufacture

No A line's `expires_on` is not strictly after its `manufactured_on`. Fix whichever date is wrong.
lot_expiry_conflict

LotExpiryConflict

No The lot number is already in stock with a different expiry. Resend the existing expiry for that lot, or use a different lot number.
line_not_on_order

LineNotOnOrder

No The `transaction_item_id` named on a return line is not on the order it was called against. Read get_order for the order's own line ids and resend.
lot_closed

LotClosed

No The named batch is expired or quarantined, so stock cannot be returned into it. Resend with a different `stock_lot_id`, or write the line off instead.
lot_invalid

LotInvalid

No The named batch is not this item's, or not this business's. Resend with a batch that actually belongs to the line, or omit `stock_lot_id`.
not_restockable

NotRestockable

No The line's components cannot go back on the shelf (a made-to-order line). Write the line off instead of restocking it.
barcode_invalid_check_digit

BarcodeInvalidCheckDigit

No An 8/12/13/14-digit barcode whose GS1 check digit does not match as sent. It is never corrected for you: the message names the likely intended value; check the number printed under the barcode and resend.
barcode_taken

BarcodeTaken

No Another item, variant or batch of this business already carries that barcode. The message names which; move it there first or send a different code.
requires_in_person_pin

RequiresInPersonPin

No The action needs a manager to approve it by typing their staff PIN on the device, in person. No token, app or job can supply a PIN, so it is never approved remotely. Tell the merchant which record needs approval and ask them to finish it on the till screen; never retryable as sent.
till_session_required

TillSessionRequired

No The account the money would move sits on a till that is not open, and the business blocks takings without an open till. Ask the merchant to open the till on the device (the till screen), then resend; not retryable until then.
payment_required

PaymentRequired

No The write would cost money, and an agent never pays. Nothing was written. Hand the checkout URL in `message`/`hints` to the merchant; the change lands when they pay. Never retry the same call to get round it.
plan_not_purchasable

PlanNotPurchasable

No The plan is not one this profile can move to — not listed for sale, an add-on, not offered for its business type, the current plan, or a slot a Friends & Family grant holds. Read `get_subscription` and pick a listed plan; the same plan fails identically.
not_profile_owner

NotProfileOwner

No Billing decisions belong to the profile owner. The caller reaches the profile but is not its owner, and no capability grant changes that. Ask the owner to connect and repeat the call.
admin_hold

AdminHold

No The subscription is on an administrator's hold, which only support can lift. Tell the merchant to contact support; never retryable.
feature_unavailable

FeatureUnavailable

No The verb is behind a rollout flag that is off on this deployment, exactly as the screen hides the same choice — or the value asked for is not supported on this surface yet (`"@inherit"`, reset a component instance's look, on a global header/footer/announcement surface). Not momentary — the identical call answers the same until it ships; set the value explicitly instead.
non_composable_type

NonComposableType

No The page component is a legacy or module section, not a composable one, so the composable read/write verbs cannot edit it; `message` names its `type_slug`. `inspect_builder_surface` lists it under `page_components` with `non_editable_reason`. Replace it with a composable section or edit it in the builder; never retryable.
removed_in_draft

RemovedInDraft

No The page component is marked removed in the unpublished draft: it no longer renders in the draft preview and publishing deletes it. Discard the draft (or restore it in the builder) to edit it again; never retryable as sent.
write_not_persisted

WriteNotPersisted

No The write reported applied but reading the row back showed the value did not stick, so the whole transaction was rolled back and nothing changed. Re-read the page and report it; repeating the identical call is not expected to help.
jsx_not_supported

JsxNotSupported

No The imported markup is JSX whose content is computed at runtime — a `{items.map(...)}` list, a `{condition && <...>}` or `{a ? <...> : <...>}` branch, or a custom `<Component />` — and a static import would silently lose it; `message` names each construct. Fragments, `<Link>`, `<Image>` and a React module that returns plain JSX still import. Render the page to HTML (or copy the rendered DOM) and import that; never retryable as sent.
starter_not_materialised

StarterNotMaterialised

No The node update named a node on a section with no stored tree. That section shows the implicit starter (`tree_source: implicit_starter` on the read), whose nodes are not stored, so they cannot be edited in place and no MCP write seeds them. Replace the starter with `create_node` or `replace_tree` (mutate_composable_tree_draft), then edit the nodes you created; never retryable as sent.
insufficient_balance

InsufficientBalance

No The loyalty write would take the customer's stamps or points below zero, or below what is held against an open bill. Read `get_loyalty_balance` for the available figure and send a smaller removal; never retryable as sent.
padding_exceeds_fixed_size

PaddingExceedsFixedSize

No A node update would make the padding on one axis (left + right, or top + bottom) reach or exceed that axis's Fixed px width or height on some device; `message` names the node, the device, the padding and the size. A frame is border-box and cannot paint smaller than its padding, so the write is refused rather than silently capped (the inspector caps it). Lower the padding below the Fixed size, or size the frame Relative, Fill or Fit; never retryable as sent.
unknown_variant

UnknownVariant

No The call named a component variant the component does not declare. `message` lists the declared keys; use one of them, or declare the variant with set_library_component_properties first. Not retryable as sent.
variant_behaviour_invalid

VariantBehaviourInvalid

No A variant's interaction override is not a list of known interactions (unknown trigger or verb, or a set_variant with no target node or variant key). Fix the list `message` names. Not retryable as sent.
variant_behaviour_too_deep

VariantBehaviourTooDeep

No A variant's interaction override nests deeper than an override value may. Flatten it and resend. Not retryable as sent.
variant_behaviour_unknown_variant

VariantBehaviourUnknownVariant

No A variant's set_variant interaction names a variant the target instance's component does not declare. Read the component with list_library_components and name a declared key. Not retryable as sent.
variant_behaviour_not_an_instance

VariantBehaviourNotAnInstance

No A variant's set_variant interaction targets a node that is not a component instance, so there is nothing to switch. Target an instance node. Not retryable as sent.
screenshot_busy

ScreenshotBusy

Retryable Every slot of the shared screenshot worker stayed busy for the whole 15-second wait (two captures run at once across the fleet). Repeat the identical call after the seconds named in `message` and `hints`; `retryable` is true.
font_axis_not_declared

FontAxisNotDeclared

No A `content.font_variation` write (or a `font_family` change under stored axes) names a variable axis the node's font does not declare. The message lists the font's axes and ranges (`get_builder_capabilities` → `element_styling_vocabulary.font_axes`); use one of them or change the font. Values inside a declared axis are clamped, never refused. Never retryable as sent.
font_setting_malformed

FontSettingMalformed

No `content.font_variation` must be text of `<axis tag> <number>` pairs (`"wght 650,opsz 20"`), `content.font_features` of allowlisted tags each optionally followed by 0 (`"tnum,liga 0"`); `"normal"` switches either off. Non-text values and more than the caps are refused. Never retryable as sent.
font_feature_not_allowed

FontFeatureNotAllowed

No An OpenType feature outside the allowlist (`element_styling_vocabulary.font_axes.features`). The message lists the allowed tags. Never retryable as sent.
reference_not_in_profile

ReferenceNotInProfile

No A module-entry write named a category, tag, media item, member role or relation target that is not in this profile; `message` names it. Use list_categories, search_media_library or list_module_entries for valid ids. Never retryable as sent.
field_schema_destructive

FieldSchemaDestructive

No A module field_schema update would remove a field or change its type, orphaning stored entry data. Send the full schema with every existing field unchanged plus the new ones. Never retryable as sent.
taxonomy_in_use

TaxonomyInUse

No A module update would switch supports_categories or supports_tags off while entries still carry categories or tags. The taxonomy is hidden, never deleted; repeat the call with the confirmation_token named in `message` to go ahead. Not retryable as sent.
route_prefix_change_unsupported

RoutePrefixChangeUnsupported

No A module route_prefix cannot change over MCP yet: every existing entry URL would need its own 301 redirect. Change the prefix in the admin. Never retryable as sent.
quota_exceeded

QuotaExceeded

No The write would create more tags than the profile's plan allows; nothing was written. Use existing tags (by id or name) or ask the owner to upgrade. Never retryable as sent.
template_slot_count

TemplateSlotCount

No A layout template (schema 2) needs exactly one content slot (`{type: "slot", name: "content"}`) anywhere inside its one template frame; `message` names how many it has. A draft is still saved and flagged; publish is refused. Never retryable as sent.
template_slot_placement

TemplateSlotPlacement

No The content slot sits inside a component instance, a repeating list (collection, record) or an overlay (modal). Move it into a plain frame of the template. Never retryable as sent.
template_slot_hidden

TemplateSlotHidden

No The content slot, or a frame around it, is hidden at a breakpoint (`message` names it), so pages would show no content there. Show it again on that device. Never retryable as sent.
template_slot_style_field

TemplateSlotStyleField

No The content slot only carries `id`, `type`, `name`, `label`, `sizing`, and `sizing` inside `tablet_overrides`/`phone_overrides`. Style the frame around it instead. Never retryable as sent.
template_slot_children

TemplateSlotChildren

No The content slot is a leaf: the page's own content renders there, so it takes no children. Put the nodes beside it in its frame. Never retryable as sent.
template_slot_position

TemplateSlotPosition

No The content slot follows the layout of its frame; it can't be absolute, fixed or sticky. Position the frame around it. Never retryable as sent.
template_root_shape

TemplateRootShape

No A schema-2 layout template `element_tree` is a list of exactly ONE frame (`type: "container"`, `template_root: true`); every other node is its descendant. Never retryable as sent.
template_schema_newer

TemplateSchemaNewer

No The layout template tree is at a schema version newer than the storefront renders yet: create_layout_template and publish_layout_template refuse a refusal-free schema-2 tree with this code (update_layout_template_draft still saves it), and the storefront skips such a template rather than misrender it. Keep it as a draft until the v2 renderer ships. Never retryable as sent.
template_chrome_behaviour

TemplateChromeBehaviour

No A classic (v1) layout template keeps its rows because its header pins with a scroll modifier (overlay, shrink, hide on scroll, progress bar) that frames cannot express yet; `message` names it. Reported in `describe_layout_template` → `chrome_conversion.declined`. Never retryable as sent.
template_chrome_shell

TemplateChromeShell

No A classic (v1) layout template keeps its rows because a chrome shell draws something outside its frames that has no frame primitive yet (for example a footer's own text colour); `message` names it. Never retryable as sent.
template_legacy_section

TemplateLegacySection

No A classic (v1) layout template keeps its rows because it holds a classic section other than header, footer or announcement bar. Never retryable as sent.
value_out_of_range

ValueOutOfRange

No A ranged inspector field, a behaviour sub-field or a state/variant style delta was written outside the range the inspector manifest declares for it (for example `appearance.transform_scale: 0.6` where Scale is 10–300 %). `message` names the field, its range and its unit; a percent field takes 60 for 60 %, never 0.6. Values already stored are never refused. Never retryable as sent.
value_wrong_unit

ValueWrongUnit

No A ranged inspector field was sent with a unit other than its own (`"60px"` for a percent field); `message` names the field's unit and range. Send a bare number in that unit. Never retryable as sent.
record_context_missing

RecordContextMissing

No A node `source`, `binding`, `link_source` or `link_binding` write on a layer with no record context around it (no collection, module entry detail / related or gallery among its ancestors). Bind layers inside a collection or gallery item. Never retryable as sent.
binding_type_unsupported

BindingTypeUnsupported

No A record binding was written on a node type that does not read it: `source`/`binding` go on heading, text, image, button, badge and tags; `link_source`/`link_binding` on the record-linkable destinations. Never retryable as sent.
binding_field_unknown

BindingFieldUnknown

No A `binding.field` or `link_binding.field` is not in the enclosing record's field catalogue for this profile, or is a type the layer cannot show (a link reads url fields only); `message` lists the fields that are. Never retryable as sent.

Domain sub-reasons. A till settings refusal keeps its reason code above (conflict or validation_failed) and names the exact rule in data.till_reason and a till_reason: … hint — branch on it, never on the message: has_sessions, session_open, mixed_control_modes, cash_account_on_another_till . cash_account_on_another_till: a counted cash account (drawer, safe, in transit) belongs to one till; card, QR and wallet accounts may be shared.

Reference — generated

5 · Response envelope

One shape for every answer. Tools used to reply in prose — "Created 'Coffee' (ID: 12)" — which forces a caller to regex an id back out of an English sentence and gives it no way to tell a permanent refusal from a momentary one. App\Mcp\Kernel\McpEnvelope fixes both halves: identifiers live in data, and every failure carries a reason code plus a derived retryable flag.

This is a payload convention, not a transport change: the envelope is JSON in the response's text content, which every MCP client already receives. The samples below were produced by calling the real encoder while this page rendered.

Success — a complete listing

{
    "ok": true,
    "data": {
        "entities": [
            {
                "entity": "order",
                "capability": "section.orders"
            }
        ]
    },
    "next": null,
    "hints": [
        "describe_entity for one entity's fields"
    ],
    "reason_code": null
}

`next` is an explicit null, never an omitted key: a caller must be able to tell "done" from "unknown".

Success — more pages to come

{
    "ok": true,
    "data": {
        "records": [
            {
                "id": 1024
            }
        ]
    },
    "next": {
        "cursor": "eyJpZCI6MTAyNH0"
    },
    "hints": [
        "query_records again with this cursor"
    ],
    "reason_code": null
}

Cursor pagination. Send the whole `next` object back as the continuation; do not construct offsets of your own.

Failure — permanent

{
    "ok": false,
    "reason_code": "not_found",
    "message": "No record 99 for entity \"order\".",
    "retryable": false,
    "hints": [
        "query_records to list ids you can read"
    ]
}

`retryable` is derived from the reason code and never passed in, so a tool cannot tell you a validation failure is worth repeating. The transport error flag is set too.

Cursor pagination, in two rules

  • A list verb always sends next — a cursor when there is more, an explicit null when there is not. An omitted key reads as "unknown", and a caller that cannot tell "done" from "unknown" either stops early or loops forever.
  • A not_found failure always carries at least one hint. Being told an id is wrong with no way to learn a right one ends the conversation.

Both are pinned by tests/Feature/Mcp/McpDiscoverabilityTest.php.

Reference — generated

6 · Model exposure

The read floor is generated, not enumerated. Four verbs cover every readable entity, and breadth comes from what each model declares rather than from another tool in the client's context window — adding an entity must never add a tool.

368

Models discovered

57

Exposed entities

0

Unproven tenancy

Counted live by App\Mcp\Exposure\ModelExposureRegistry, which discovers models by walking the filesystem rather than reading a registration list — a list is the thing that goes stale, and the whole mechanism rests on a model nobody registered still being found.

The three attributes

AttributeEffect
#[McpExposed] Names the entity the read verbs answer for, and the capability a caller must hold. It is not enough on its own: the model must also prove it stays inside one storefront — a real tenant global scope, a named local scope that actually exists as a method, or a declared profile column. A model that cannot prove one is reported by list_entities rather than dropped from it, because an entity simply missing from a listing is indistinguishable from one the agent misspelled, and the agent then retries forever.
#[McpDenied] An explicit answer of "no". Distinct from an undeclared model so that "nobody has decided yet" and "we decided against it" are not the same silence. A denied entity now refuses with a REASON SENTENCE, not "unknown entity". 154 of the 154 denied entities carry prose written once in App\Mcp\Exposure\DeniedEntityReasons and quoted verbatim, because a machine slug like holds_bearer_credentials gets paraphrased by the model, usually wrongly. A structural denial names what to read INSTEAD; a sensitive one says the row is never readable, so the agent stops rather than retrying a misspelling it did not make. abandoned_cart — Each row carries a cart recovery token, which is a bearer credential — anyone holding it can resume that customer's checkout — so the row is never readable here.
#[McpWithheld] Field-level. A withheld field comes back null with a stated reason. That is not an error and not a missing value: the data exists and the caller is not being given it — so a client must not retry it, guess it, or tell the merchant it is empty.

Nothing outside the exposed map is reachable by any generated read verb, which is what makes an undeclared model unreadable rather than merely undocumented.

A denial can also mean "read it through its own verb". CmsLayoutTemplate is denied to the generic floor because the builder's layout template verbs serve it with a versioned tree, the lift from an older schema and the refusals a draft would hit on publish, each with its reason code. The generic floor would hand back raw columns in whichever schema the row happens to hold.

The generated read verbs

VerbWhat it answers

Entities readable on this deployment

EntityModelTenancy
account Account Proven
account_type AccountType Proven
auto_content_generation AutoContentGeneration Proven
business_identity BusinessIdentity Proven
category Category Proven
category_type CategoryType Proven
conversation Conversation Proven
coupon Coupon Proven
customer Customer Proven
discount_rule DiscountRule Proven
external_contact ExternalContact Proven
form_submission CmsFormSubmission Proven
guest_document GuestDocument Proven
inventory Inventory Proven
inventory_transaction InventoryTransaction Proven
item Item Proven
item_addon_group ItemAddonGroup Proven
item_addon_option ItemAddonOption Proven
item_choice_group ItemChoiceGroup Proven
item_variant ItemVariant Proven
kitchen_order_ticket KitchenOrderTicket Proven
kot_item KotItem Proven
message Message Proven
outlet Outlet Proven
outlet_settlement_entry OutletSettlementEntry Proven
party Party Proven
party_address PartyAddress Proven
payment_method PaymentMethod Proven
pms_floor Floor Proven
profile Profile Proven
profile_printer ProfilePrinter Proven
purchase_receipt PurchaseReceipt Proven
purchase_receipt_line PurchaseReceiptLine Proven
return_request ReturnRequest Proven
review Review Proven
service_location ServiceLocation Proven
shipment Shipment Proven
stock_lot StockLot Proven
storefront_customer StorefrontCustomer Proven
storefront_customer_address StorefrontCustomerAddress Proven
storefront_visitor_event StorefrontVisitorEvent Proven
till Till Proven
till_account TillAccount Proven
till_count TillCount Proven
till_device_register TillDeviceRegister Proven
till_handover TillHandover Proven
till_movement TillMovement Proven
till_policy_layer TillPolicy Proven
till_session TillSession Proven
till_session_account TillSessionAccount Proven
till_settlement TillSettlement Proven
till_settlement_line TillSettlementLine Proven
till_user_assignment TillUserAssignment Proven
transaction_item_lot TransactionItemLot Proven
transaction_status TransactionStatus Proven
transaction_type TransactionType Proven
wishlist Wishlist Proven

Build

7 · Adding a capability

Start by assuming you are not adding a tool. Almost every new write is a DECLARATION over a service the app already has, and a declaration costs the client nothing: the tool list stays flat while coverage grows, which is what keeps a small model able to drive this app at all. Reach for a tool class only when a NAME buys a model something a declaration cannot.

A · The usual case — declare the capability

The worked example to read first is app/Mcp/Write/Actions/AdjustStockAction.php: one attribute, one perform() that marshals arguments into InventoryAdjustmentService::adjust(), and one preview(). No policy of its own — the capability, the tenant and the confirmation all happened in perform_action before the action object existed.

  1. 1 · Extend App\Mcp\Write\McpAction

    Put it in an Mcp/Write/Actions/ directory — that path is how the registry finds it, in the app or in any package. Implement perform(McpToolContext $context, array $arguments): array, answering ['ok' => bool, 'data' => array]. Nothing else: an action holds no policy.

  2. 2 · Declare #[McpWritable] on it

    action (snake_case verb name) · summary (one sentence naming the EFFECT, not the implementation — it is all a model sees in a listing) · service and method · capability · screenRoute · tier · arguments (a closed list of McpArgument) · previewMethod.

  3. 3 · service must be the service the SCREENS already use

    Never raw Eloquent. A second write path skips the observers, ledger rows and alerts that ride the first one — which is how manual stock adjustments stopped firing low-stock alerts before InventoryAdjustmentService existed. The registry proves the method exists and refuses to serve the declaration if it does not.

    Several attributes silently do nothing when written directly: Transaction::$status is computed and read-only (use OrderStatusTransitionService::apply()); a Transaction created without reserving a number never gets one; Account::$balance is fillable and a direct write skips the audit event; a Coupon written without CouponRuleSynchronizer::sync() discounts nothing. After a write that changes public output, invalidate with StorefrontCacheInvalidator — never cache:clear.

  4. 4 · capability must equal what the SCREEN demands

    Not "a capability that sounds right", and not a stricter one either. A parity test reads the answer out of the app's own PermissionSurfaceRegistry AND the route's own permission: middleware for the screenRoute you named, and fails the build if the declaration demands anything else. Declaring a capability that gates nothing would refuse an agent where a team member holding the same screen succeeds; declaring a weaker one would be a permission bypass. There is no default and the parameter is not nullable — an ungated declaration is a fatal TypeError the moment the attribute is read, which is the build failing rather than a permissive fallback.

  5. 5 · tier is mandatory, and Tier C needs a preview

    McpTier::Write for anything a merchant can undo from a normal screen in under a minute with no data loss. McpTier::Consequential when it moves money or stock, reaches a public surface, fans out beyond one record, sends something to a third party, or has no undo screen — and then previewMethod is required, because a token can only be issued by a preview of the same arguments. McpTier::Read is refused outright for a write; use query_records for anything that changes nothing.

    If an author and a reviewer disagree, it is Consequential. If an author is unsure, it is Consequential. Over-classifying costs one round trip; under-classifying costs money or stock moving on a retry nobody looked at.

  6. 6 · Write the preview so it reports what a model cannot derive

    AdjustStockAction::preview() reports the current balance and the resulting one, because quantity_delta is a delta and a caller that meant "set it to 12" has no other way to discover it is about to add twelve to eighty. Answer the same ['ok', 'data'] shape; a preview that answers ok: false issues no token.

  7. 7 · Register nothing

    There is no list to add to and no server to touch. McpWriteRegistry sweeps Mcp/Write/Actions/ by filesystem, so the action is performable the moment the file exists — and appears in §3 of this page in the same deploy. The tool budget does not move, which is the point.

B · When a bespoke tool class is still right

Write one when the NAME is what lets a model choose correctly, when the verb needs a schema or a response shape the declaration's closed argument list cannot express, or when it has its own preview partner that is itself a useful read verb. Then: extend Laravel\Mcp\Server\Tool; resolve the tenant only through ResolvesMcpContext::context() with the capability the equivalent screen uses — never Profile::find($request->get('profile_id')), which authorizes nothing because the caller chose the id, and which eleven tools shipped; return an McpEnvelope, never prose; annotate it; add it to BusinessServer::$tools and bump the server's #[Version]; and add a budget line, which only ever moves DOWN.

A tool class must also declare #[McpTiered].

ToolTierRegistry::unclassifiedWrites() is ratcheted at 0 — a write that declares no tier and does not hint readOnlyHint: true fails the build. It is not treated as safe in the meantime either: an undeclared, non-read-only tool is ENFORCED as Tier C, so forgetting the attribute costs a confirmation token rather than buying a free pass. A Tier C class must name its previewTool, because a refusal that says only "confirmation required" leaves a small model with nowhere to go.

The one exception is a tool whose tier belongs to what it was asked to run — perform_action runs a Tier B rename and a Tier C stock movement from the same file. Those implement App\Mcp\Write\DelegatesTierPerCall and resolve the tier per call instead.

The tests that will tell you what you got wrong

  • tests/Feature/Mcp/McpWriteLayerTest.php — capability parity against the screen, a mandatory tier, a preview for every Tier C, and the unclassified-writes ratchet.
  • tests/Feature/Mcp/McpToolBudgetTest.php — budgets, name-prefix collisions, platform-wide name uniqueness, and "every registered server has a budget line".
  • tests/Feature/Mcp/McpDiscoverabilityTest.php — next on every list verb, a hint on every not_found, and no reason code outside the enum.
  • tests/Feature/Mcp/McpDevelopmentDocsTest.php — this page. It fails when a server, a tool, a tier, a declared action or a reason code is missing from it.