Ordering over MCP
MCP is one of two doors to the same ordering; REST carts is the other, with the same checks behind both. Mint a build session for a provisioned user, point an MCP client at our server, and drive the order tool. The session builds and reads a cart, and confirms it once the person says yes, with the 6-digit code Layout texts them when a code is due. In sandbox a cart is built from the restaurant’s menu as Layout holds it, with no browser, and the confirm is always simulated; it takes no code unless the cart was built to ask for one. A person with their own Layout account can instead connect through an OAuth client you created, and confirm from your app.
Connect
Mint a build session with POST /v1/users/:id/grant and send its lgb_ token as a bearer credential. The grant authenticates the session for that one provisioned user, with no OAuth flow. A secret key sent to mcp.layout.link is refused with 401. An expired build grant answers 401 grant_expired on both hosts, and its WWW-Authenticate carries error_description="grant_expired". Mint a new grant. Any other refused grant answers unauthorized. Every answer that comes before the call is on MCP tools.
When the person has their own Layout account and signs in themselves, connect with an OAuth client you created instead. The MCP client starts the standard authorization flow at https://mcp.layout.link, whose metadata names https://api.layout.link as the authorization server, and the session acts as that person, who approved your app on Layout's consent screen.
{
"mcpServers": {
"layout": {
"url": "https://mcp.layout.link",
"headers": { "Authorization": "Bearer lgb_9f3a2c…" }
}
}
}
The tools
A build session has three tools: order, order_status and places. A sandbox build session also has menu. order is a state machine rather than separate calls: action walks it through preview, build, status and confirm. An OAuth connection acts as the person, so it gets every Layout tool, including menu and item_photo.
| Tool | What it does |
|---|---|
order | Resolve a restaurant, build a cart and confirm it. Driven by action, below. |
order_status | Long-poll a build (by idempotencyKey) or a placement (by orderId, which also takes the ord_ id) until it settles. Read-only and free. A ready cart reads placement.status: "carted": stop polling, it waits on the person. A placed order carries placement.pickup. |
places | Find restaurants by name or description, from the restaurants Layout holds. It never opens a restaurant's site. In sandbox it lists only restaurants whose menu Layout holds, and an empty list it emptied for that reason carries emptyBecause: "no_held_menu": search another restaurant, kind of food or area. |
menu | Sandbox build sessions only. The menu Layout holds for a place, the one a sandbox cart there is priced from. It never reads a menu live, costs nothing, and can be called whenever you need to check what a place sells. mode: "knowledge" answers the same way. A place with none answers status: "unreadable" with code: "menu_not_held". |
Every tool's full input and output JSON Schema, and the raw JSON-RPC calls, are on MCP tools. How each tool, state and id maps to REST is in Two doors, one order.
An OAuth connection also has item_photo: what one menu item looks like, from the restaurant's own published photo. It answers photo with an imageUrl, pending while Layout is still looking (ask again shortly), or why there is none. It is the same lookup as POST /v1/images/items, which a build session can use over REST.
The order actions
| action | What it does |
|---|---|
preview | Resolve the restaurant and estimate. Pass the restaurant’s name in restaurant (or in query), not the food. A name Layout cannot find answers store_not_found either way. |
build | Start building the cart. Holds for up to about 15 seconds: a build that finishes inside that window comes back with its result, carted when the cart is ready. Otherwise it returns building and you poll status for the rest. A build session orders pickup only: a build with fulfillment: "delivery", deliverTo or tipPercent builds nothing and answers deliveryUnavailable: { reason: "not_enabled" }. |
status | Poll a build by idempotencyKey, or a placement by orderId. |
confirm | Place the cart the person approved. On a production build session it needs the code Layout texts the person when a code is due: the call without one then texts it and answers confirm.error: "code_required". When none is due it places. On a sandbox build session for a provisioned test user it is always simulated. On an OAuth connection it places a real order, passing the code Layout texted the person in code when the build's confirmation.method is sms_code; for a sign-in test account it is simulated and answers simulated: true. |
A build that carries a placeId and a restaurant or restaurantName naming a different store builds nothing and answers storeMismatch: store is the store the placeId names, with its address, and candidates are stores matching the name when Layout could find them. Layout compares every word of the name you send against the store’s name and address, as POST /v1/carts does. Ask the person which one they mean and build again with that store's placeId, its name as restaurant, a near, the order in query and a fresh idempotencyKey. When a build asks which branch (needsLocation), build with the chosen option’s placeId and its own name as restaurant. A build needs query and a store to build on: platform, an https orderUrl, or a restaurant name in restaurant or restaurantName with near or geo. Pass the placeId too when you have it. A placeId alone is refused as invalid_input.
When the person says no, call order with action: "status", cancel: true and the cart's orderId to release it. The schema requires action. A cart still building has no orderId yet: cancel it with that build's idempotencyKey. The answer is cancel.status: "canceling", Layout releases the cart the moment the build lands and never texts the person about it, and a later cancel answers canceled. A build session can cancel by key only a build its own application started; for any other key the answer is no_active_order. A cancel on a build session naming neither an orderId nor an idempotencyKey is refused as forbidden. A cart already confirmed answers already_placing or placed, and is not canceled.
What a preview concluded
A preview result carries preview.outcome, one word for what Layout found. Branch on it rather than working it out from the other fields.
| outcome | What it means |
|---|---|
ready | A store resolved and nothing stands in the way. Call build next. |
needs_location | Layout needs to know which branch. Offer the options in preview.nearby and preview again with the chosen placeId. |
closed | The store is closed right now. Relay preview.sayToUser; it names any open alternative. |
closing_soon | Open, but too close to closing for Layout to take the order. |
not_orderable | The restaurant takes no online orders Layout can place. |
blocked | Layout does not place orders at this restaurant. |
store_not_found | Layout found no restaurant by that name. preview.storeNotFound.candidates are restaurants search found under other names, within about 30 miles; offer them by their own names, never as branches, and build at one only if the person picks it, with its placeId and its own name as restaurant. A build with a name and no placeId answers the same way. |
When the person names a street and exactly one open branch sits on it, the preview resolves that branch instead of asking which one, and the confirmation card still names the store before anything is bought.
Confirm with the person's code
On a build session, a person who has not yet agreed to your application ordering for them is asked for a code on their first order (first_order_consent), and that code is the agreement. When they have already agreed, and which other confirms ask for a code, is in When a code is due. Call confirm with no code either way; it answers placing, or code_required when a code is due. When the person is not ready to confirm, it answers build_only with confirm.next.step (verify_required, connect_required, phone_required or card_required), and texts nothing. On card_required, mint a card link and send it to them. A build session gets no confirmationCard: tell the person the items, the store and price, the build's totalMinor, which includes Layout's service fee, and wait for their yes. cart.merchantTotalMinor is the restaurant's own total.
A confirm that goes ahead answers in confirm.status: placing, code_sent after a resend, code_not_needed, already_placing, placed, not_confirmable_cold, or session_gone when the cart's checkout session ended and it must be built again. not_confirmable_cold carries reason: canceled, expired, closed or not_found. It can also answer failed, with a code, when nothing was placed; confirm_expired or not_placed, when nothing was placed and the cart must be built again; confirmed; or place_error. A confirm on a carted build more than fifteen minutes after it was ready answers confirm_expired, and nothing is charged. On place_error, relay userMessage, follow assistantInstruction, and poll status with the orderId: the outcome can be unknown. A confirm Layout refuses is still an ordinary tool result, not isError: confirm.status is "error" and confirm.error names the reason, so branch on confirm.error. code_required, build_only, price_changed and send_failed all arrive this way.
- Call
confirmwith theorderId, the carted build'stotalMinor(fee included) asexpectedTotalMinor, a freshidempotencyKeyand nocode. When a code is due, the answer isconfirm.error: "code_required", and Layout texts the person a code that names the total, the store and the card, in Layout's words.codeSent: truemeans it just went out.codeSent: falsemeans a code Layout texted for this cart in the last 10 minutes is still good and nothing new was sent: ask the person for that one. A text that could not be sent answerssend_failed: retry shortly.texts_blockedmeans the person's number replied STOP to Layout and nothing was texted. - Ask the person for the code.
- Call the same
confirmagain, with the sameidempotencyKey, pluscode. The answer isplacing; pollstatuswith theorderIduntil it isplaced,failedorunconfirmed.
The code works once, for this cart and this total, for 10 minutes. stage: "resend" texts a fresh one, which replaces the last, and answers code_sent; when no code is due it answers code_not_needed and texts nothing. Layout texts at most four codes per cart: past that, or past your application's daily code allowance, the confirm answers rate_limited with a reason. A wrong code answers bad_code, and a code that no longer fits answers expired, amount_mismatch or no_pending_auth. On too_many_attempts, wait retryAfterSec, ask the person for the code again and confirm with it. On limiter_unavailable, when Layout could not count the tries, or a rate_limited with retryAfterSec, wait that long and send the same call again. A session can confirm only carts it built. Never invent a code, and never ask the person for a Layout sign-in code: an order code only approves the one cart it names.
Other refusals carry the same confirm.error shape. These confirm and charge nothing: price_changed (the total no longer matches: build a fresh cart with the same request and a new idempotencyKey, which carries the moved total, tell the person, and confirm with it), over_daily_cap (the account's daily spending limit), risk_denied, idempotency_conflict (the key was used for a different request; retry with a fresh one) and unavailable (a check could not run; retry the same confirm shortly). in_progress means a confirm with this idempotencyKey is still running: poll status with the orderId, and never confirm again. confirm_failed can come after the order was handed off: Layout lost track of the confirm and the order may be placing. Follow assistantInstruction, poll status with the orderId, and never confirm again.
A build session's confirm that sends the restaurant's total alone, without Layout's service fee, answers price_changed with reason: "fee_not_included". The price did not move: confirm with the build's totalMinor, the total the person was shown.
Build in sandbox
A sandbox session builds at the restaurants its places tool lists, which in sandbox are only those whose menu Layout holds. A build also takes simulate (the ending), latency (realistic or instant) and engine (shadow or real), exactly as Sandbox describes. A production session that sends any of them is refused with sandbox_only. A restaurant whose menu Layout does not hold, or a build that names no place Layout can tie to a placeId, answers a build refusal with refusal.code: "menu_not_held": pick another place from places. A cart that fails ITEM_UNAVAILABLE named something the menu does not list, or lists as sold out: call menu with that placeId and build again with names it lists. A cart that answers needs_choices: ask the person, then build again with the same query and store, a new idempotencyKey, and choices set to every answer given so far, joined with "; ". How the request is matched to the menu is in How a sandbox cart is built. The tools a sandbox session lists, and its instructions, are in MCP tools.
Confirm in sandbox
Pass the orderId from the carted build, its totalMinor, which includes Layout's service fee, as expectedTotalMinor, and a fresh idempotencyKey. No code is needed, except on a cart built with "simulate": "code" or at test_place_code: its confirm answers confirm.error: "code_required" with codeSent: false until it carries "code": "000000", and nothing is texted. Any other code answers bad_code.
order {
"action": "confirm",
"orderId": "0b6f2a54-…",
"expectedTotalMinor": 1949,
"idempotencyKey": "a7c1e0d2-…"
}
→
{
"action": "confirm",
"confirm": {
"status": "placed",
"orderId": "0b6f2a54-…",
"totalMinor": 1949,
"merchantTotalMinor": 1850,
"subtotalMinor": 1701,
"simulated": true,
"userMessage": "This is a test account, so that was a simulated order. No money moved, no card was charged, and no restaurant received it."
},
"publicOrderId": "ord_7c21e4b9a0d3"
}
A build session’s confirm carries three amounts: totalMinor, the total you confirmed with, fee included, on every confirm about an order that went ahead, including already_placing and an alreadyConfirmed answer, where it is the order’s own total; merchantTotalMinor, the restaurant’s own total; and subtotalMinor, the food before tax, absent when Layout cannot read it. simulated: true means no restaurant received the order and nothing was charged. Never describe it as a real order. A sandbox cart at a real restaurant, and test_place_placed, test_place_failed, test_place_unconfirmed, test_place_challenge, test_place_code and test_place_pickup_later, answer the confirm with status: "placing" and simulated: true: poll status with the orderId for the ending. Every other test restaurant answers placed in the same call, as above. A total that no longer matches the cart is refused with price_changed, just as in production. A simulated order still sends an order.placed webhook, and its order object carries "simulated": true, as does GET /v1/orders/:id. A real order never carries that key. A resend stage on a sandbox session texts nothing and places nothing: it answers code_not_needed, or code_sent on a cart built with "simulate": "code" or at test_place_code; nothing is texted, and the code (000000) is not in userMessage. A sandbox session releases a cart it built the same way as production, with action: "status", cancel: true and the orderId, or the build’s idempotencyKey while it builds. A preview with a test_place_ placeId, or a restaurant naming a Layout Test Kitchen store, resolves that test restaurant.
Relay the card verbatim
On an OAuth connection, a finished build carries a confirmationCard that Layout renders server-side with the store, items, total and pickup time. Relay it to the person exactly as returned. Never claim an order is placed until the status is placed. That is the only status that means the order exists.
Ask only from Layout's data
When a build comes back needs_choices, the restaurant is insisting on something and choiceDecision.groups carries their exact wording. Offer those options, nothing else, and build again with the answer in choices.
Resist filling gaps from what you know about the brand. You have no idea what this particular store stocks today, and a question you invented costs the person a turn on an option that may not exist.
After the confirm
Already confirmed elsewhere
The person can confirm the cart themselves, by replying YES to Layout's text, in the Layout app, or on Layout's tracking page. A confirm after that sends nothing to be placed and charges nothing again. Its result carries alreadyConfirmed: true and orderState, the order's real state: placing, placed, unconfirmed or refunded. confirmedVia is sms or tracking_link when Layout knows where. status is already_placing while it is placing, placed once it is, and not_confirmable_cold for the other two. While it is placing or unconfirmed the result also carries complete: false and nextAction: "order_status": poll status with the orderId. Relay its userMessage, and never call confirm or build the order again.
An unconfirmed placement
When Layout submitted the order and the restaurant’s site never said whether it went through, the placement poll answers status: "unconfirmed". The order may exist. Do not say it failed, and do not build or confirm it again: tell the person to check their email or call the store before ordering again. Later polls of the same orderId answer unconfirmed until Layout learns the outcome, and can then answer placed, for instance when the restaurant's receipt arrives; order.placed says so too. A sandbox test order that ends unconfirmed answers status: "unconfirmed" too, with outcome: "unconfirmed" and failureCode: "UNCONFIRMED". Read GET /v1/orders/:id with your secret, whose state is unconfirmed, or the order.unconfirmed webhook, for the order’s state.
The order id for REST
Once a result names an order, from carted on, it also carries publicOrderId: the ord_ id that GET /v1/orders/:id and your webhooks use. It appears only on orders your build session or your OAuth client drove. While a cold build is still building there is no order id yet; keep polling, or take it from the order.building webhook.
Reading a menu
On an OAuth connection, menu reads the restaurant's live menu. A person gets 30 live menu reads a day, and an answer Layout already has does not count. Past that, menu answers from general knowledge when it can (status: "knowledge", live: false), and otherwise comes back limit_reached; with retryAfterSec, wait that long. Never quote a knowledge answer as the store's current prices. A production build session has no menu tool. A sandbox build session’s menu answers only with the menu Layout holds, and with code: "menu_not_held" when it holds none.
Texts Layout sends
Every text Layout can send a person about an order your application started, over REST or MCP, for a user you provision or through an OAuth client you created. Each comes from Layout's number. In sandbox nothing is texted.
Every other text, including verification codes and connect links, with its exact copy, is on Texts Layout sends.
| Text | When Layout sends it | Can you turn it off |
|---|---|---|
| The cart, to reply YES | A ready cart that nothing confirmed or canceled about 15 seconds after it landed (see If the card is missed). Layout answers the person's YES in the same conversation. | Yes, with Cart-ready text |
| Cart reminder | Once, a few minutes later, when that cart is still waiting. | Yes, with Cart-ready text |
| Order code | A production confirm that needs the person's code. It names the total, the store and the card (see Confirm with the person's code, or code_required over REST). | No |
| Approval link | The restaurant's checkout asks the person to approve the purchase, and order.challenge is sent. It names the total and the store. If the person asks for a change on that page and it cannot be made, Layout texts that nothing was ordered or charged. | No |
| Order placed | The order is placed. It says so, with the pickup details Layout has at that moment. | Yes, with Order texts |
| Pickup time | The pickup time arrives after the order was placed (see Pickup time). One text with the time. | Yes, with Order texts |
| Order did not go through | The order failed, or its cart failed while it was building. | No |
Both settings are on the App details page of the console, are on for every application, and cover every order your application starts. A change applies to every text not sent yet, including for orders already in flight.
Turn Order texts off when your product tells the person their order is placed and when to pick it up, and you want them to hear it only from you. Your application then owns both: read them from order.placed, order.updated and GET /v1/orders/:id. It never stops Layout's receipt email or push notifications. A person you provisioned who has no email on their Layout profile and no Layout app then hears nothing from Layout about the order, so tell them yourself.
Two orders are always texted, whatever the setting:
- An order Layout already texted a failure for, that is placed after all (the store's email confirms it later). The person has to hear that it went through, or they may order again.
- An order the person confirmed by replying YES to Layout's cart-ready text. They are already in a text thread with Layout, and its reply told them a text would follow.
No setting stops an order code, an approval link or the text saying an order did not go through. A person who never hears that an order failed may order again and be charged twice.
Texts that set a person up, such as a verification code or a connect link, are on Provisioning.
If the card is missed
If nothing confirms or cancels a ready cart about 15 seconds after it lands, Layout texts the person the same cart: the store's name and address, each item, the total, and the card it goes on. For a cart your application built, the text opens with your application's name as it is in the console: Acme Assistant built your order at Layout test kitchen, 1 Sandbox Way, San Francisco, CA 94107:. They reply YES to place it; once a code is out for the cart, a YES points them to the code instead. If the cart is still waiting a few minutes later, Layout sends one reminder. A cart already confirmed or canceled, even while it was still building, gets no text, and neither does a number that opted out of Layout texts. An order that needs an extra confirmation step tells them to open the Layout app instead of replying.
If your own chat carries the confirm and you want the person to hear only from you, turn off Cart-ready text on the App details page of the console. A build session then confirms itself, with the person's code when one is due.