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.
| Server | Endpoint | Tools | Version |
|---|
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=aiaddress 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 verb | Get its token from | What 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.
| Tool | Server | What 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.
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.
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
-2and applying-200fails. 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_draftis 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
conflictand 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-1cannot 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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
party_id |
integer | yes | The customer (party). Must belong to this business. |
resend_member_payment_link
Tier B · Write
section.membership
Send a paid-tier member their pending payment link again, by WhatsApp when connected, else SMS. Throttled to 1 a minute and 5 a day per member.
Writes through MemberEnrolment::resendPaymentLink()
· same capability as the membership.members.index screen
| Argument | Type | Required | Description |
|---|---|---|---|
party_id |
integer | yes | The customer (party) whose payment is pending. |
tier_id |
integer | yes | The paid membership tier they were added to. |
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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()
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Argument | Type | Required | Description |
|---|---|---|---|
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.
| Code | Retry? | 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_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 explicitnullwhen 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_foundfailure 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
| Attribute | Effect |
|---|---|
#[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
| Verb | What it answers |
|---|
Entities readable on this deployment
| Entity | Model | Tenancy |
|---|---|---|
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 · Extend
App\Mcp\Write\McpActionPut it in an
Mcp/Write/Actions/directory — that path is how the registry finds it, in the app or in any package. Implementperform(McpToolContext $context, array $arguments): array, answering['ok' => bool, 'data' => array]. Nothing else: an action holds no policy. -
2 · Declare
#[McpWritable]on itaction(snake_case verb name) ·summary(one sentence naming the EFFECT, not the implementation — it is all a model sees in a listing) ·serviceandmethod·capability·screenRoute·tier·arguments(a closed list ofMcpArgument) ·previewMethod. -
3 ·
servicemust be the service the SCREENS already useNever 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
InventoryAdjustmentServiceexisted. 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::$statusis computed and read-only (useOrderStatusTransitionService::apply()); aTransactioncreated without reserving a number never gets one;Account::$balanceis fillable and a direct write skips the audit event; aCouponwritten withoutCouponRuleSynchronizer::sync()discounts nothing. After a write that changes public output, invalidate withStorefrontCacheInvalidator— nevercache:clear. -
4 ·
capabilitymust equal what the SCREEN demandsNot "a capability that sounds right", and not a stricter one either. A parity test reads the answer out of the app's own
PermissionSurfaceRegistryAND the route's ownpermission:middleware for thescreenRouteyou 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 fatalTypeErrorthe moment the attribute is read, which is the build failing rather than a permissive fallback. -
5 ·
tieris mandatory, and Tier C needs a previewMcpTier::Writefor anything a merchant can undo from a normal screen in under a minute with no data loss.McpTier::Consequentialwhen 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 thenpreviewMethodis required, because a token can only be issued by a preview of the same arguments.McpTier::Readis refused outright for a write; usequery_recordsfor 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 · Write the preview so it reports what a model cannot derive
AdjustStockAction::preview()reports the current balance and the resulting one, becausequantity_deltais 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 answersok: falseissues no token. -
7 · Register nothing
There is no list to add to and no server to touch.
McpWriteRegistrysweepsMcp/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—nexton every list verb, a hint on everynot_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.