web/docs/connect-an-agent.md
Connect an agent to AimoPilot
AimoPilot speaks two open interfaces with the same operations and the same refusals:
- MCP (Model Context Protocol) over Streamable HTTP at
/api/mcp, and at a per-kitchen connector address/api/mcp/c/<token>for assistants that can only be given a URL (section 0). Protocol revision 2026-07-28, plus the 2025-era handshake (2025-11-25 and earlier) that most clients still send. - REST described by OpenAPI 3.1 at
/api/v1/openapi.json.
An agent that knows only the base URL can find both: /.well-known/api-catalog (RFC 9727), /.well-known/dock.json (AimoPilot's own manifest) and /llms.txt.
The kitchen's own agent in the store is one more client of the same tool table and the same gate: what it can look up, an outside agent can look up, as structured data. What we assumed, what is mocked and what a production deployment would need are in assumptions.md.
What the kitchen's own agent can do, and an outside agent
The store's agent and an outside agent call the same core functions through the same gate. Nothing below is a side door: every row passes the same mandate check, admission (suspension, licence) and gate.
| The kitchen's own agent (store thread) | An outside agent: MCP tool | REST |
|---|---|---|
| Reads the buyer's words, checks them against memory, asks at most two questions, offers good / better / best | submit_need, respond_to_concerns , answer_questions , choose_option, get_intake_step | POST /intake, /intake/{id}/concerns, /answers, /choice |
| Product grid and product facts (smart search; labels exact, variant, stand-in) | find_products (with live price, stock, delivery, cut-off) | POST /lookup |
| Alternatives picker, re-ranked by price, CO2, reliability, kind, pack | find_alternatives | POST /alternatives |
| Order sheet: several lines, one order | request_quote with several lines, then place_order { requestId } | POST /quotes, POST /orders |
| "Make it 200": the new order replaces the old one | place_order with replaces | POST /orders with replaces |
| Take back before the cut-off (the kitchen's signed-in person) | withdraw_order: an order waiting for sign-off, or one this agent placed alone | POST /orders/{id}/withdraw |
| Waits for the sign-off, then says the outcome | get_order with waitSeconds, list_orders | GET /orders/{id}?wait=25, GET /orders |
| Memory, history, the mandate's rules | read_kitchen_memory, read_mandate, list_orders | GET /kitchen, GET /orders |
| "Why?": the audit trail | explain | GET /ledger |
| Offers from the wholesaler's selling agent | list_offers, accept_offer, decline_offer, mute_offers (offers.md) | /api/v1/offers |
| A menu around what is in season: the anchor (season calendar, with its source; products the range has), the dishes, per ingredient the need, what is on hand (the kitchen's history or the person's words, each labelled; nothing assumed when nothing is known) and the shortfall in the smallest covering pack | plan_seasonal_menu | POST /menus/seasonal |
| A booking at short notice: what cannot arrive by the day and why (cut-off at a time, stock, no delivery), the earliest day; substitutes by the ingredient's role in the dish, verified (in the range, arrives in time, the kitchen's rules and nutrition rules), best / better / good with trade-offs and the gate's word | plan_event_menu (for one product that is short: find_alternatives) | POST /menus/event |
| The planned menu as one order: substitutes declared so the gate judges them; the agent takes a swap alone only where the gate passes it | order_menu | POST /menus/{planId}/order |
| Stock-out watch: a key product that cannot come for its day, good / better / best substitutes with trade-offs, the signed availability claim; "shall I switch?" | watch_orders (fresh: true checks live now), answer_watch { findingId, switch | keep }; more substitutes find_alternatives, take back withdraw_order | GET /watch, POST /watch/answer |
| Standing orders: the learned weekly pattern, drift (portions, cost), auto-renew placed or held and asked | watch_orders (standing, renewals), answer_watch { renewalId, renew_new | usual | skip }, set_auto_renew | GET /watch, POST /watch/answer, POST /watch/auto-renew |
| Changing the kitchen's rules | not offered: only the kitchen's signed-in person signs new terms; an agent reads them (read_mandate) | none |
| Approving an order | never a tool, on either side: the named person's passkey on their own phone | approver operations only (?audience=agent leaves them out) |
* carries the buyer's own answer: asked through the client's form when it can show one; otherwise relayed and recorded as not verified.
Which kitchens an outside agent can buy for. Every kitchen has a mandate and a connector address: the two built-in demo kitchens (kaari, laakso), a visitor's hotel kitchen made by scanning the wall's QR code (its approver is the visitor's phone), and an account kitchen anyone can open at /k/signup (its terms are signed by the account itself). Account and visitor kitchens' keys are held by the server in this demo; in production the kitchen holds its own key (see assumptions.md).
What can change under an agent.
- A kitchen that tightens its rules reissues its mandate; every earlier mandate is refused from that second (rule
mandate_window). - When the wholesaler enforces crash-test licences (
DOCK_LICENCE_ENFORCE=on), the gate applies the stricter of the mandate and the licence the agent earned on the public crash test; an agent with no licence bound to it sends every order to a person. The result says when the licence, not the mandate, did. - A relayed answer ("the chef said yes") is recorded as relayed, not verified. It does not count as a person's: where it matters, the order goes to a named person's sign-off.
The examples use a local server. Replace http://localhost:3000 with the deployed URL.
0. Connect a consumer assistant with only a URL
Consumer assistants (the Claude apps, ChatGPT) let a person add a remote MCP server by URL, with OAuth or with no sign-in, but not with the two request headers below. So every kitchen also has a connector address:
https://<dock>/api/mcp/c/dk_<43 random characters>The token in the path stands for that kitchen's signed mandate and agent id, which stay on the server. A call through the address is exactly a call to /api/mcp with those headers: the same mandate check, the same gate, the same live-lookup allowance, the same human sign-off. It adds nothing. No tool approves an order, and the token is not an approval proof.
| Get it | The kitchen portal's "connect your own agent" section, from GET /ui-api/connect/<kitchen> (address plus setup steps for each assistant). |
| Visitor kitchens | Random at first request. Expires with the sandbox (90 minutes by default). |
Demo kitchens (kaari, laakso) | Derived from DOCK_CONNECTOR_SECRET, so the address is the same after every restart and deploy. Set it on the deployment and configure the assistant once, before the demo. Without the secret, the address is random per process. |
| Revoke / reissue | DELETE / POST /ui-api/connect/<kitchen>. The old address stops at once (HTTP 404, and a refusal in the ledger). Visitor kitchens: the visitor's own phone session. Demo kitchens: Authorization: Bearer <DOCK_CONNECTOR_SECRET>. A revoked demo address comes back after a restart, because it is derived from configuration; change the secret to retire it for good. |
| Ledger | A request opened through an address gets an intake entry with data.step: "channel", via: "connector", the address id (con_…, not the secret) and the client's User-Agent. |
| Errors | An unknown, revoked or expired address answers 404, never 401. A 401 with WWW-Authenticate makes Claude and ChatGPT start an OAuth sign-in, and AimoPilot has none to offer. |
Limits on one address
A leaked address cannot spend beyond the mandate:
- Above the per-order limit, a named person signs off on their phone.
- The weekly budget is a hard cap that no sign-off lifts.
- Out-of-mandate products are refused.
To bound load, each address also has its own limits. They come on top of the per-agent live-lookup allowance, which is shared with every other door of the same agent:
| What | Limit | Refusal |
|---|---|---|
HTTP requests (handshakes, tools/list, calls) | 120 a minute | HTTP 429 with Retry-After |
New purchase requests (submit_need, open_request) | 20 an hour | tool refusal connector_rate_limited, rule connector_rate_limit |
place_order calls, counted before the gate, so a person's phone cannot be flooded | 6 an hour | same |
| Request body | 256 KiB | HTTP 413 |
Connector refusals are written to the ledger at most once per address and reason every ten minutes, so a hammered address cannot flood it.
What the consumer assistants accept (vendors' own docs, read 2026-11-13)
Most vendor pages carry no date. Where one did, it is given.
| Assistant | How a user adds a server | Transport | Auth it supports | Plan | Confirmation and human input |
|---|---|---|---|---|---|
| Claude (claude.ai web, Desktop; mobile uses connectors added there) | Customize > Connectors > Add > Add custom connector: a name and the URL. Team/Enterprise: an owner adds it under Organization settings > Connectors. | Streamable HTTP (recommended); legacy SSE still accepted | No sign-in, or OAuth: CIMD ("Claude's published identity"), DCR (RFC 7591), or your own client id. PKCE S256, RFC 9728 metadata, found from a 401. Custom headers only in a limited beta. Callback https://claude.ai/api/mcp/auth_callback | All plans; Free gets one custom connector | Asks before tool calls (Allow once / Always allow). Per tool: Always allow / Needs approval / Blocked; readOnlyHint sets the default. Anthropic does not document elicitation; "advanced or draft capabilities" are listed as unsupported. Tool results up to about 150k characters, 240 s per call. |
| ChatGPT (web only) | Plugins > + > Add custom MCP server: name, URL, Authentication "No authentication", tick "I understand and want to continue", Create as a plugin, install. Use with @ in the prompt box. | SSE and Streamable HTTP | No auth, OAuth (static client, CIMD https://chatgpt.com/oauth/client.json, then DCR; PKCE S256; RFC 9728; RFC 9207 iss), or mixed per tool. No custom API keys or headers. Callback https://chatgpt.com/connector_platform_oauth_redirect | Write tools: Business and Enterprise/Edu (on Business, admins and owners create). Pro: read/fetch tools only. Plus: not stated. | "Write actions by default require confirmation"; a tool without readOnlyHint counts as a write. Elicitation is recommended by the Apps SDK docs, but how ChatGPT shows it is not documented. ChatGPT reads the server's instructions. |
| Gemini app | Settings > Connected Apps > Custom apps > Add a custom app (web; works on mobile once added) | not stated | DCR, or client credentials under Advanced features. No-auth servers are not mentioned. | 18+, in the US, personal account, English only. Not available in Finland. | "Gemini requires manual confirmation for any write actions." |
| Gemini CLI | gemini mcp add --transport http dock <url>, or mcpServers.dock.httpUrl in settings.json | stdio, SSE (url), Streamable HTTP (httpUrl) | headers (so the header route works too), OAuth discovered on 401 with DCR | Since 18 June 2026 only with a paid Gemini API key or Code Assist Standard/Enterprise; free and Google One users were moved to Antigravity CLI | Asks per tool call unless trust: true. Elicitation is not documented, and the client source registers only roots. |
| Claude Code | claude mcp add --transport http dock <url> | Streamable HTTP, SSE, stdio | Headers, OAuth | any | Asks per tool unless allowed. Elicitation forms are shown "only in interactive terminal sessions". Headless (-p), forms come back cancel. |
So a plain URL is enough for the leading assistants: Claude on every plan, ChatGPT on Business/Enterprise, Claude Code, and Gemini CLI. AimoPilot does not implement OAuth. The one consumer assistant that seems to need it, the Gemini app, is not offered in Finland anyway.
Sources:
- Claude:
- add an unlisted connector
- authentication
- lazy authentication
- building
- getting started
- troubleshooting
- help centre: custom connectors (updated late October 2026)
- help centre: connectors on mobile (updated the week of 9 November 2026)
- ChatGPT:
- help centre: custom MCP servers (updated about 27 October 2026)
- developer mode
- Apps SDK auth
- Gemini:
- Gemini app custom apps
- Gemini CLI MCP (updated 6 October 2026)
- move to Antigravity CLI (19 May 2026)
- Other:
- Claude Code MCP
- MCP authorization (revision 2026-07-28: authorization is optional; a server that uses it must publish RFC 9728 metadata; CIMD preferred, DCR deprecated)
Menus move. Check the vendor page if a label differs.
Setup in two minutes, once the app is at its public HTTPS address
Copy the kitchen's connector address from the portal ("connect your own agent"), or:
curl -s https://jolly-wondrous-bunny.eur-1.aiven.app/ui-api/connect/kaari | jq -r .connector.urlClaude (claude.ai or Claude Desktop, any plan)
- Sign in at claude.ai, or open Claude Desktop.
- Open Customize > Connectors, click Add, then Add custom connector.
- Fill in:
- Name:
AimoPilot (Ravintola Kaari) - Remote MCP server URL: paste the address.
- Name:
- Click Continue. If Claude asks how to sign in, choose No sign-in. Then click Add, then Connect.
- Optional, to avoid a prompt on every step: go to Customize > Connectors > AimoPilot > Tool permissions and set the read tools to Always allow. Leave
place_orderon Needs approval if you want Claude to ask before ordering. - Start a new chat. Check that AimoPilot is switched on under + > Connectors.
- Type: "Using AimoPilot, order 1,000 litres of lactose-free milk for our kitchen, delivered by Monday." Click Allow once (or Always allow) when Claude asks to use a AimoPilot tool.
- Answer the question Claude relays from AimoPilot ("1,000 L where you usually order about 100 L?").
- Pick an option. The approver's phone (or the portal's sign-off QR code) then asks for the sign-off.
The connector also shows up in the Claude mobile app at its next sign-in.
ChatGPT (web; Business or Enterprise/Edu workspace, as an admin or owner on Business)
- Sign in at chatgpt.com in a browser.
- Open Plugins, click +, then Add custom MCP server.
- Fill in:
- Name:
AimoPilot (Ravintola Kaari) - Server URL: paste the address.
- Authentication: No authentication.
- Name:
- Tick I understand and want to continue, click Create as a plugin, then install it.
- In a new chat, type @ and pick AimoPilot, then the same request as above.
- ChatGPT asks you to confirm each write step (
submit_need,respond_to_concerns,choose_option,place_order). Confirm each one. Answer AimoPilot's question when ChatGPT relays it.
On a Pro account, ChatGPT connects only read/fetch tools, so it cannot order.
Claude Code / Gemini CLI
claude mcp add --transport http dock <address>
gemini mcp add --transport http dock <address> # needs a paid Gemini API key or Code Assist Standard/EnterpriseWhat was proven, and with which client (local server, 13 November 2026)
| Client | Result |
|---|---|
Stock @modelcontextprotocol/client, given only the URL: no headers, no elicitation, the 2025-11-25 handshake a consumer assistant uses (scripts/connector-smoke.ts) | 14 tools, none that approves. Ran mandate, need, the mistake check relayed, options, quote and order, to pending_approval with "Do not retry". |
Claude Code 2.1.323 (claude -p --mcp-config with only {"type":"http","url":…}, model Opus) | Turn 1: it called read_mandate and submit_need, then stopped. It put AimoPilot's question to the user ("Did you mean 100 L?") with both choices and did not answer it. Turn 2: the user confirmed 1,000 L. Claude Code declares elicitation but cannot show forms headless, so the form came back cancel. It then used the relay: true fallback and showed the three option cards in plain euros. Turn 3: the user picked option 1. It called choose_option and place_order (pending_approval), told the user in one sentence that Jari Mäkelä must sign off on his phone, and did not retry. |
Gemini CLI 0.25.2 (settings.json with only httpUrl; model gemini-3.5-flash-lite, on the team's free-tier key, because the CLI's default models are retired and the 3.5-flash quota was used up) | First run: every AimoPilot call failed with no schema with key or ref "https://json-schema.org/draft/2020-12/schema" (Ajv, draft-07 default). AimoPilot now publishes its schemas without the $schema keyword; 2020-12 is MCP's default anyway. Second run: it relayed the mistake check correctly. It then corrupted the long signed quote token while copying it into place_order, three times; the gate refused each one (quote_signature), but the model told the user the order was sent. place_order now takes only requestId after choose_option. Third run: need, relayed check, choice, then place_order (pending_approval, first try). It told the user Jari Mäkelä must sign off and did not retry. Two lapses remain, both the small model's: it chose the pre-selected cheapest option without asking, and it filled by with an approver's name from the mandate. |
pnpm demo:mcp on a production build (14 November, afternoon): the stock client as a hotel chain's agent, given only a visitor hotel's connector address, MCP 2026-07-28 with elicitation | 22 tools, none that approves. find_products showed the partner's own exception: the 10 L and 21 L boxes' cut-off (Thu 15:00) had passed for Monday, and the 1 L cartons still made it. The intake answered from memory without asking and quoted the cheapest option (€1,010). It waited for sign-off over the €500 limit, and a second order was refused (quote_replay). The passkey sign-off followed, get_order with waitSeconds learned "committed" 2 s later, and the receipt verified. The four exceptions passed: substitutes ranked, a nut drink refused (forbidden_allergens), today past the cut-off, and a 20 L order taken back before the cut-off. |
| Claude app, ChatGPT | Not run: no consumer account here. Follow the steps above against the deployed HTTPS address. |
To check again against any address: pnpm tsx scripts/connector-smoke.ts <address>. It places one order, which then waits for sign-off.
On a production build (pnpm build && pnpm start), the smoke script reaches pending_approval too. Revoking a demo kitchen's address without the operator secret gets 403.
Before the demo:
- Set
DOCK_CONNECTOR_SECRETon the deployment. Otherwise the demo kitchens' addresses change with every restart, and the assistant has to be set up again. - Make sure the deployment runs this build. The public address was still serving an older build without
/api/mcp/c/…on 13 November.
In a chat assistant: questions and sign-off
Most chat assistants cannot show AimoPilot's own form (elicitation), so the assistant relays. These rules are written for a model that has never seen AimoPilot, and they appear in three places: the server instructions, each tool's description, and each result's nextStep.
waitingFor: "buyer": ask the person AimoPilot's question in plain words with its choices, stop, and pass on their reply. The original request does not count as the answer.pending_approval: tell the person in one sentence who must sign off, on their own phone, and stop. Do not retry, place it again, split it, or find a way around it.- A refusal is final unless
nextStepsays how to fix it: tell the person, and do not loop. - After
choose_option,place_orderneeds onlyrequestId. AimoPilot places the quote it signed for that request, so the model never has to copy a long signed token. by(the buyer's name) is optional through a connector. When the assistant does not say, AimoPilot records the kitchen's person "through the kitchen's connector". A chat assistant should not have to ask your name.- If the client declares elicitation but closes the form unanswered (
cancel, as Claude Code does headless), the result isform_not_answered. For that request only, the agent may then ask in the chat and send the reply withrelay: true. An explicitdeclinestill stops.
The production version: how a buyer organisation would delegate to its assistant
A URL that carries a secret works for a demo, and AimoPilot keeps it narrow:
- one address per kitchen
- revocable and expiring
- rate-limited
- no more than the mandate
It is still a bearer secret, in a place where URLs get copied and logged. Anthropic's connector guide says not to accept tokens in URLs.
The production version is standard MCP authorization (OAuth 2.1), with the mandate kept as the grant:
- The connection. The buyer's purchasing manager adds
https://<dock>/api/mcp(no token) in their assistant.- AimoPilot answers 401 with RFC 9728 protected-resource metadata pointing at AimoPilot's authorization server, or at the buyer's own identity provider.
- The assistant identifies itself with its Client ID Metadata Document (Claude's or ChatGPT's published identity, so no registration step), with PKCE and an RFC 8707
resource.
- The consent screen is the mandate. The manager signs in with their organisation account and sees what they are delegating:
- which kitchen, and which categories
- the per-order limit and the weekly budget
- who signs off above the limit, and for how long
On approval, the organisation signs a mandate naming that assistant as the agent: its client id, not a made-up agent id. The access token references the mandate (scope
mandate:<id>), is short-lived, and is bound to the client (DPoP), so a copied token is useless. Refresh tokens are revocable from the organisation's portal. - Enterprise version. The organisation's IdP issues the assertion, using MCP's enterprise-managed authorization (an identity-assertion grant), so IT decides centrally which assistants may buy and for whom.
- Nothing else changes.
- The gate still checks every order in plain code against the signed mandate.
- Sign-off still happens on the approver's own device with a passkey.
- The ledger names the assistant's client id and the human who consented.
- Rate limits are per client and per organisation, in shared storage (the in-memory limits here are per process).
1. Get a mandate
Every call carries the mandate the buyer organisation signed for its agent, plus the agent id it names:
Authorization: Bearer <mandate JWS> (Authorization: Mandate <jws> works too)
Dock-Agent-Id: <agent id>The mandate is a JWT (typ: mandate+jwt, aud: dock) signed by the organisation's own Ed25519 key. AimoPilot holds only the organisation's public key. There is no OAuth flow and no AimoPilot account. Listing MCP tools works without a mandate; every tool call checks it.
For local runs, mint one for the fictional demo buyer, Laakso Catering Oy (valid 7 days):
cd web
export DOCK_MANDATE=$(pnpm -s dev:mandate) DOCK_AGENT_ID=agent_laakso_buyer_012. Claude Code
claude mcp add --transport http dock http://localhost:3000/api/mcp \
--header "Authorization: Bearer $DOCK_MANDATE" \
--header "Dock-Agent-Id: $DOCK_AGENT_ID"Or check a project-scoped .mcp.json into a repo. Claude Code expands environment variables in it:
{
"mcpServers": {
"dock": {
"type": "http",
"url": "http://localhost:3000/api/mcp",
"headers": { "Authorization": "Bearer ${DOCK_MANDATE}", "Dock-Agent-Id": "${DOCK_AGENT_ID}" }
}
}
}Then ask, for example: "Using the dock tools, order 1,000 litres of lactose-free milk for Monday." The tool descriptions steer the model to start with submit_need and to bring AimoPilot's questions back to you rather than answer them itself.
3. Claude Desktop
The simplest way is the kitchen's connector address as a custom connector (section 0): no sign-in, no headers. To present your own mandate instead, go through the mcp-remote bridge, which forwards custom headers (Claude's own custom-header support is a limited beta). Put the headers in a file so the mandate stays out of the process list:
# ~/.dock-headers.txt
Authorization: Bearer <paste the mandate>
Dock-Agent-Id: agent_laakso_buyer_01claude_desktop_config.json (Settings > Developer > Edit Config):
{
"mcpServers": {
"dock": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/api/mcp", "--header-file", "/Users/you/.dock-headers.txt"]
}
}
}Add --allow-http only if AimoPilot runs over plain HTTP on another machine in a trusted network. Loopback and HTTPS need nothing extra.
4. Any other MCP client
Point it at the URL and set the two headers. With the stock TypeScript client:
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
const client = new Client({ name: "my-buyer", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } });
await client.connect(
new StreamableHTTPClientTransport(new URL("http://localhost:3000/api/mcp"), {
requestInit: { headers: { Authorization: `Bearer ${mandate}`, "Dock-Agent-Id": agentId } },
}),
);
const { tools } = await client.listTools();
const r = await client.callTool({ name: "read_mandate", arguments: {} });
console.log(r.structuredContent);pnpm build && pnpm demo:mcp (or pnpm demo:mcp --url http://localhost:3000) does Valio Aimo's example this way, with no LLM and no AimoPilot client code, as a hotel chain's agent. The hotel's purchasing manager joins with a passkey, and the agent gets the hotel's connector address. It looks up products, prices and availability (find_products) and submits the need. It gets good, better and best options, chooses one and places the order. The order is over the agent's €500, so it waits. The manager signs with the passkey, get_order with waitSeconds returns the outcome, and the script verifies the signed receipt. Then four exceptions follow: a stock-out with ranked substitutes, a nut drink refused as a substitute, delivery today past the cut-off, and an order placed alone, then taken back.
Tools
Start every order with submit_need. The lower-level tools below the line stay for follow-up.
| Tool | What it does |
|---|---|
read_mandate | What this agent may buy, spend and substitute, and who signs off above that. |
submit_need | Start here. The buyer's need as fields. Returns a step: needs_confirmation, needs_input, options_ready, quoted, no_options or cancelled. |
respond_to_concerns * | The buyer confirms or corrects a likely mistake ("1,000 L where you usually order 100 L"). |
answer_questions * | The buyer answers at most two typed questions AimoPilot's memory could not answer. |
choose_option | The option card the buyer picks. Returns a signed fixed-price quote and the exact place_order arguments. |
get_intake_step | Where an order stands, and the call to make next. |
read_kitchen_memory | Order history, usual amounts, rules people stated, patterns learned. |
place_order | Order against a quote: committed, pending_approval or refused. Just requestId after choose_option or request_quote. replaces changes an earlier order. |
get_order | Status, decision, approval and the signed confirmation. With waitSeconds (up to 55) it answers once the person decides a waiting order. |
list_orders | This organisation's orders: which wait for a person's sign-off, who must sign, what can still be taken back. |
withdraw_order | Take back an order waiting for sign-off, or one this agent placed alone, until the wholesaler's order cut-off. What a person signed is theirs to take back. |
explain | Plain-language audit trail for a request or order, with chain verification. |
find_products | Products, prices and availability in one call. Filters: words, category, brand, claims, allergens to avoid, pack size, delivery day, quantity. Words go through the store's own product search, and each product says match: exact, variant, stand_in or not_the_kind. Live for at most 10. |
find_alternatives | Good, better, best substitutes for a product that is short, late or not wanted. Each comes with its trade-offs, live price and delivery, and the gate's preview. |
open_request | Lower level: a bare request with no mistake checks or options. |
search_products | Lower level: product master data. Free; no live call. |
check_availability | Lower level: live price, stock and delivery for at most 10 products. Metered. |
request_quote | Lower level: signed fixed-price quote, plus what the gate would decide on it. |
list_offers | The wholesaler's signed offers to this kitchen (new, seasonal, or a cheaper like-for-like), each verifiable offline. Never an order. |
accept_offer | The buyer wants it: starts an ordinary order through the intake, the gate and the sign-off. |
decline_offer | Not wanted; not offered again for 30 days. |
mute_offers | No more of this product, family or kind of reason, or no offers at all (scope: all). |
There is no tool to approve or decline an order: only the approver can, on their own phone with their passkey.
The intake, step by step
Every intake result is { ok, step, waitingFor, nextStep }. step.state is where the order stands, waitingFor is buyer, agent or nobody, and nextStep names the exact call to make next.
{ "ok": true, "waitingFor": "buyer",
"step": { "requestId": "req_…", "state": "needs_confirmation",
"concerns": [{ "key": "quantity_tenfold:1000:L", "message": "1,000 L where you usually order about 100 L: did you mean 100 L?",
"confirmable": true, "corrections": [{ "id": "use_100_L", "label": "Make it 100 L" }] }] },
"nextStep": "AimoPilot found a likely mistake. Ask the buyer: … Then call respond_to_concerns … This answer must come from the human buyer …" }* The buyer answers, not the agent
respond_to_concerns and answer_questions carry the human buyer's own answer. The agent relays AimoPilot's question and passes on what the buyer says; it must never confirm a likely mistake or answer a question for them. While a confirmation is pending, request_quote and place_order on that request are refused with rule human_input, so the check cannot be skipped by going around the intake.
The MCP specification has no tool annotation for "needs a person" (annotations are only the readOnlyHint, destructiveHint, idempotentHint and openWorldHint hints). What the 2026-07-28 revision does define is elicitation through input_required results: a tool answers with an elicitation/create form, the client shows it to the user (not the model), and retries the call with the user's answer. AimoPilot uses exactly that:
- Client declares the
elicitationcapability (form mode): AimoPilot asks through the client's form. The agent's arguments, if any, only pre-fill it; what comes back is what counts, and a declined form changes nothing (codebuyer_declined). The form is shown and returned by the agent's client, and AimoPilot cannot tell a client that asked its user from one that filled the form in itself, so the answer is recorded as relayed by the client (channelprotocol_elicitation, not verified). It settles preferences inside the mandate; where it confirms a likely mistake that matters, the order still goes to a named person's sign-off. - Client without elicitation (or a 2025-era connection, which this stateless server cannot push requests to): the agent passes the buyer's answer as
byandresponses/answers, recorded as relayed and not verified. A call with no answer is refused with codehuman_answer_required. The step also carriesanswerUrl, a one-time page on AimoPilot (30 minutes, single use, bound to that exact step) where the buyer taps one of AimoPilot's fixed choices themselves; the agent is told to hand it over instead of relaying. Answers there are recorded with the channelanswer_link. Whoever holds the link can answer, so without the kitchen's signed-in session on that phone a confirmation that matters (an unusual amount, a rule broken) still goes to a named person's sign-off.
Both tools also carry _meta: { "dock/humanInput": { "required": true, … } } in tools/list and humanInput: true in the manifest; in the OpenAPI document the same two operations carry x-dock-human-input. Those keys are AimoPilot's own, not a standard. Operations for the approver's phone are tagged approver (x-dock-audience: approver); /api/v1/openapi.json?audience=agent leaves them out, so an OpenAPI-to-tools converter never offers an agent a tool to approve orders.
Output schemas in tools/list are published shallow (the top-level fields with their descriptions, one level of shape below): some runtimes send every output schema to their model on each call. The full shapes are in the OpenAPI document.
Results and refusals
Every result has structuredContent (checked against the tool's published output schema) and the same JSON as text for clients that read only text:
{ "ok": true, "order": { "status": "pending_approval", ... },
"nextStep": "This order now waits for a human sign-off by Aino Virtanen (kitchen_manager) ... Do not retry ..." }A refusal is a tool result with isError: true. It carries the same code and rule the REST API returns:
{ "ok": false,
"error": { "code": "policy_rejected", "rule": "weekly_budget", "message": "This €1,322.00 order would take weekly spend to €2,644.00, over the €2,000.00 budget ..." },
"nextStep": "The weekly budget is a hard limit that no sign-off can lift. Do not retry or split the order; tell the buyer." }Live price and availability is for short lists only: at most 10 products per call, and per agent at most 30 different products and 30 live lookups an hour. Quotes count, because a quote asks for live prices. Going over is refused with rule live_lookup_limit.
5. Plain REST with curl
Same headers, same flow. The OpenAPI document lists every status and schema.
DOCK=http://localhost:3000
H=(-H "Authorization: Bearer $DOCK_MANDATE" -H "Dock-Agent-Id: $DOCK_AGENT_ID" -H "content-type: application/json")
# start from a structured need; every reply is { step, waitingFor, nextStep } (plus answerUrl when the buyer must
# answer), and step.next is the next call with an example body whose answers are placeholders, never a pre-filled choice
STEP=$(curl -s -X POST $DOCK/api/v1/intake "${H[@]}" \
-d '{"need":{"item":{"text":"lactose-free milk","category":"milk"},"quantity":{"amount":20,"unit":"L"},"deliverOn":"2026-11-17"},"by":"Aino"}')
echo "$STEP" | jq '{state: .step.state, waitingFor, nextStep, answerUrl, next: .step.next}'
# then POST .step.next.path with the buyer's answer: /intake/{id}/concerns, /answers, /choice, and finally
# POST /orders with just {"requestId": …}: AimoPilot places the quote it signed at the choice.
# "deliverOn" asks for that exact day; "deliverBy" for that day at the latest.The lower-level calls, for follow-up or a quote built line by line:
# open a request
REQ=$(curl -s -X POST $DOCK/api/v1/requests "${H[@]}" \
-d '{"text":"20 L lactose-free milk","neededBy":"2026-11-16"}' | jq -r .request.requestId)
# search master data (no live call)
curl -s "$DOCK/api/v1/products?requestId=$REQ&q=milk&attribute=lactose_free&limit=3" "${H[@]}" | jq '[.products[].id]'
# live price and stock for a short list
curl -s -X POST $DOCK/api/v1/availability "${H[@]}" -d "{\"requestId\":\"$REQ\",\"productIds\":[\"VA-1001\"]}" | jq .offers
# signed quote
Q=$(curl -s -X POST $DOCK/api/v1/quotes "${H[@]}" \
-d "{\"requestId\":\"$REQ\",\"lines\":[{\"productId\":\"VA-1001\",\"quantity\":20}]}")
echo "$Q" | jq '{total: .quote.totalCents, preview: .policyPreview.outcome}'
# order exactly the quote: 201 committed, 202 waits for sign-off (do not retry), 422 refused
curl -s -X POST $DOCK/api/v1/orders "${H[@]}" \
-d "$(echo "$Q" | jq -c --arg r "$REQ" '{requestId: $r, quoteToken: .quoteToken, lines: [.quote.lines[] | {productId, quantity, unitPriceCents}]}')" \
| tee order.json | jq '{status: .order.status, error: .error}'Products, prices and availability in one call, and alternatives (the same as find_products and find_alternatives):
curl -s -X POST $DOCK/api/v1/lookup "${H[@]}" \
-d '{"text":"lactose-free milk","requiredClaims":["lactose_free"],"quantity":{"amount":1000,"unit":"L"},"deliverOn":"2026-11-16","limit":4}' \
| jq '{requestId, products: [.products[] | {name, match, price: .live.unitPriceCents, stock: .live.availablePacks, day: .live.meetsDay, orderBy: .live.orderBy}]}'
curl -s -X POST $DOCK/api/v1/alternatives "${H[@]}" -d '{"productId":"VA-1010","quantity":{"amount":1000,"unit":"L"}}' | jq '.alternatives[] | {rank, name, tradeOffs, gate: .gate.outcome}'After the order:
curl -s "$DOCK/api/v1/orders?status=pending_approval" "${H[@]}" # what waits for sign-off
curl -s "$DOCK/api/v1/orders/$ORDER?wait=25" "${H[@]}" # answers once the person decides
curl -s -X POST $DOCK/api/v1/orders/$ORDER/withdraw "${H[@]}" -d '{"reason":"breakfast cancelled"}'
# change an order: quote the new lines, then POST /orders { requestId, replaces: "<old orderId>" }POST /orders with only { requestId } also works after POST /quotes: AimoPilot places the latest quote it signed for that request.
Errors are always { "error": { "code", "rule", "message", "details" } }.
6. Verify a receipt without trusting AimoPilot
A committed order's confirmation is a compact JWS (typ: order+jws) signed by the wholesaler. The public keys are an RFC 7517 JWK Set at /.well-known/jwks.json (also /api/v1/keys), Ed25519 per RFC 8037. Any JOSE library can verify it offline:
pnpm verify:receipt order.json --url $DOCK # fetch the keys
curl -s $DOCK/.well-known/jwks.json > jwks.json
pnpm verify:receipt order.json --jwks jwks.json # fully offlineThe script (scripts/verify-receipt.ts) uses only jose and the published keys. Edit one byte of the receipt and it fails. Without DOCK_WHOLESALER_JWK set, the server makes a new key each time it starts, so keep the JWK Set that was live when the order was signed.
7. Offers from the wholesaler's selling agent
The wholesaler's own agent may offer a kitchen a product worth trying: new in the assortment, in season in Finland, or a like-for-like cheaper per litre, more reliable or lower in carbon than what the kitchen buys every week. Each offer is a signed W3C Verifiable Credential (compact JWS, typ: vc+jwt, type DockProductOffer, Ed25519, addressed with aud to the buyer organisation), checked like a receipt against the published keys:
curl -s $DOCK/api/v1/offers "${H[@]}" > offers.json
pnpm verify:offer offers.json --url $DOCK --org <your org id>An offer buys nothing: accept_offer (or POST /api/v1/offers/{offerId}/accept) starts an ordinary order, and the same intake, gate and sign-off decide it. Tell the buyer about at most one or two, in plain words, and only on their own answer accept, decline or mute. The format, the reasons and every guardrail are in offers.md.