Gateway contract v2
ARCHIVE
Archived research / exploratory draft. Not the current implementation contract. Read REST 1.0 and the current concept.
Draft for agreement · 2 October 2026 · not implemented. This page proposes the exact contracts behind decision 002. Nothing described here runs yet. The module still speaks Bridge Protocol v1, and Agentfy cannot yet send the call context this contract needs. The PHP side must be agreed with Artem Belikov and the Agentfy side with the Agentfy team before implementation starts (STOR-17).
One agent call crosses three separate locks, and each lock has its own key. Agentfy proves to Store Deputy which agent is calling. Store Deputy decides which person or shopper the conversation acts for and what they may do. Store Deputy then proves to the shop that the request comes from it. No key opens more than one lock, and the model never holds any of them.
What an owner would notice: a member of the team cannot do more through the agent than their role allows; two people chatting with the same agent never see each other's context; pausing the module in OpenCart stops everything at once, whatever happens on our side; a request that timed out is checked, not repeated, so nothing is added to a cart twice.
The machine-readable contract is in the repository's contracts/ folder. bun run contracts:check checks that its files agree with each other and that every example matches its schema. It proves the documents are consistent, not that anything works.
| File | What it fixes |
|---|---|
contracts/module-rest/openapi.yaml | The module REST API, OpenAPI 3.1 |
contracts/module-rest/signature-vectors.json | Test values for signatures, for PHP and TypeScript |
contracts/store-deputy/module-callbacks.openapi.yaml | What the module calls in Store Deputy (Stage B) |
contracts/mcp/tools.yaml | Store Deputy MCP tools, their schemas and examples |
contracts/context/call-context.yaml | Headers Agentfy sends, grants, and the resolved context |
contracts/errors.yaml | Every error code, its HTTP status and what the agent sees |
Three authority boundaries
Agentfy runtime ──(1) agent credential + (2) conversation grant──▶ Store Deputy MCP
│ policy, audit
│
(3) connection signature, never (1) or (2) ────┘
▼
OpenCart module REST API| Boundary | Proof | Issued by, held by | Valid only at | Never |
|---|---|---|---|---|
| Agent → Store Deputy | Authorization: Bearer sdak_…, one per provisioned agent | Store Deputy; Agentfy keeps it as a team secret | Store Deputy MCP | Forwarded to a shop, shown to the model, accepted as a person's authority |
| Person or shopper | X-SD-Context: sdcg_…, one per conversation | Store Deputy; Agentfy carries it per conversation | Store Deputy MCP, for that agent and thread | Holds permissions of its own; survives a revoked membership |
| Store Deputy → shop | HMAC with the connection secret, both directions | Store Deputy and the module, at pairing | That one shop's module | Leaves Store Deputy's servers or the shop |
The shopper's storefront session is a fourth secret, and it never leaves the shop. The module verifies it and hands Store Deputy only an opaque reference (see shoppers).
Store Deputy MCP
Transport. One endpoint, {STORE_DEPUTY_API}/mcp, Streamable HTTP, protocol version 2025-06-18 (the version Agentfy's client sends today). It is stateless: Agentfy opens a new MCP session for every call, so initialize must be cheap and no state may hang on Mcp-Session-Id. tools/list returns every tool in one page, because Agentfy does not follow nextCursor.
Tools for the Admin Agent (Stage A). All read-only, all need the catalog.read capability except store_status.
| Tool | Does |
|---|---|
store_status | Which shop, reachable or not, paused or not, which tools work now |
catalog_find_products | Find by SKU, model, EAN and other identifiers; returns every match, never picks one |
catalog_get_products | Current details by product ID; missing IDs are listed |
catalog_list_products | Page through the catalog, optionally only recent changes |
Tools for the Frontend Agent (Stage B): cart_get, cart_add_item, cart_update_item, cart_remove_item. Price changes stay off until STOR-5 has its own approval check.
Rules every tool follows:
- No argument names a store, team, person, customer, conversation, price authority or permission. Every object schema forbids extra properties. Passing
store_idis refused withinvalid_arguments; it never selects anything. The check script enforces both. - Names use letters, digits and underscores only. Model providers behind Agentfy reject dots, and Agentfy silently drops names a provider cannot accept.
- A tool is listed only if the agent's profile includes it, the shop's manifest declares its capability, and the team's entitlement allows it. Calling a tool that is not listed is refused on the server, not only hidden.
- Results. Success returns
structuredContentand the same JSON as text. Failure setsisErrorand returns{ "error": { "code", "message", "retryable", "details" } }. Agentfy does not readisErrortoday, so the message must make sense to the model on its own. - Shop text is data. Product names and option labels are returned as fields, never merged into instructions. A product called "ignore previous instructions" changes nothing.
- IDs are text (
"42") so the schema stays the same on every platform; money is a decimal string with four places.
Trusted context
What Agentfy sends
| Header | Set by | Store Deputy checks |
|---|---|---|
Authorization: Bearer sdak_… | Agentfy, from the team secret storedeputy:Authorization (works today) | Known, not revoked; gives team, store and profile |
X-Agentfy-Agent-Id | Agentfy runtime | Equals the agent the credential was issued to |
X-SD-Context: sdcg_… | Agentfy runtime, per conversation | Exists, not expired or revoked, issued for this agent and this thread |
X-Agentfy-Thread-Id | Agentfy runtime | Equals the thread the grant was issued for |
X-Agentfy-Turn-Id | Agentfy runtime | Audit only |
X-Agentfy-Tool-Call-Id | Agentfy runtime | Becomes the stable operation key of a change |
X-Agentfy-Initiated-By | Agentfy runtime | human, heartbeat or agent; only human turns may carry a person's grant |
The model sets none of these. The headers ride on the same TLS request as the agent credential, so they are as trustworthy as Agentfy itself; a signed context token is not needed unless a relay is ever placed between Agentfy and Store Deputy.
What Store Deputy derives
On every tool call, before any shop request, Store Deputy turns the headers into one resolved context: team, store, profile, actor and effective scopes. Tool code sees only that result.
| Actor | When | May do |
|---|---|---|
employee | A person opened the conversation in the app; grant names a Store Deputy user | The intersection of the grant, their live team role, the agent's profile, the entitlement and the shop's capabilities |
agent | A scheduled or agent-to-agent turn, no grant | Reads the owner allowed for the agent; never a change that needs a person's approval |
shopper | A visitor opened the storefront chat; grant names a shopper binding | Their own cart only, while the module still confirms the binding |
Changing a role, removing a member, disconnecting the store or revoking a grant takes effect on the next call, because nothing is cached in the grant. A grant from one conversation is refused in another, so two parallel conversations of one agent cannot mix.
What Agentfy does not do yet
These come from reading Agentfy's main on 2 October 2026. They are requirements for STOR-22 and related tasks, to agree with the Agentfy team, not changes to make here.
| Today in Agentfy | Needed |
|---|---|
| MCP calls carry only team-shared static headers; by policy no agent, user or conversation context is sent | Opt-in, per MCP server, the headers listed above, set by the runtime |
| No per-conversation secret | Store a Store Deputy grant per thread, outside the prompt, and send it on that thread's calls |
| Visitor turns get no MCP tools at all | Allow them for a server that is marked as frontend (Stage B) |
isError is not inspected | Treat it as a failed tool step (recommended) |
| Cabinet threads are keyed by Agentfy user | A separate thread per Store Deputy person, so their grants never meet |
| No service credential; creating agents and MCP servers needs a user session | A service-to-service way to provision an agent (STOR-23) |
How Agentfy receives a grant when a conversation starts depends on who opens the conversation, which is still open (see open points). The proposal is that Store Deputy opens it, because the app is where the person signed in.
Module REST API
Transport
The shop has one entry point, reported at pairing as endpoint_url (for OpenCart, storedeputy.php in the shop root). The logical path travels in a path query parameter, because many hosts do not pass the rest of the URL to PHP:
GET https://shop.example/storedeputy.php?path=/v2/catalog/products&limit=100HTTPS only, JSON in UTF-8, bodies at most 1 MB, no redirects followed. The response signature decides whether the module answered, not the HTTP status: a CDN challenge, a maintenance page or a PHP fatal error has no valid signature and counts as "shop unreachable". A signed non-2xx answer is a real refusal with an error body.
| Operation | Stage | Works while paused |
|---|---|---|
GET /v2/health — reachable, clock | A | yes |
GET /v2/manifest — platform, limits, capabilities, warnings | A | yes |
POST /v2/connection — pairing | A | yes |
DELETE /v2/connection — disconnect | A | yes |
POST /v2/connection/secrets — rotate the secret | A | yes |
GET /v2/catalog/products — page through products | A | no |
POST /v2/catalog/product-lookups — by ID and by identifier | A | no |
GET /v2/operations?keys=… — what happened to a change | A | no |
GET /v2/shopper-bindings/{ref}/cart | B | no |
POST /v2/shopper-bindings/{ref}/cart/items | B | no |
PATCH, DELETE /v2/shopper-bindings/{ref}/cart/items/{id} | B | no |
Signatures
Every request and every response carries X-SD-Connection, X-SD-Key-Id, X-SD-Timestamp, X-SD-Nonce, X-SD-Request-Id and X-SD-Signature: sd2=<hex HMAC-SHA256>. Authorization stays unused because common PHP setups do not pass it through.
SD2-REQ SD2-RES
{connection_id} {connection_id}
{key_id} {key_id}
{timestamp} {timestamp}
{nonce} {nonce}
{request_id} {request_id}
{METHOD} {nonce of the request}
{logical path} {status}
{canonical query} {sha256 hex of body}
{sha256 hex of body}- The canonical query is every parameter except
path, names and values percent-encoded as in RFC 3986, sorted by name, joined with&. A repeated parameter is refused. - The HMAC key is the secret's text as UTF-8 bytes, as in v1.
- A response is bound to the request's nonce, so an answer cannot be replayed to another request. v1 did not do this.
- The module refuses a timestamp more than 300 seconds from its clock and a nonce it has seen in the last 10 minutes. The nonce store is required in v2; v1 left it unbuilt.
- The module's own calls to Store Deputy (Stage B) use
SD2-MREQandSD2-MRES, so a signature from one direction can never be used in the other.
Pairing uses SD2-PAIR with a Store Deputy pairing key (ECDSA P-256) instead of a connection secret, and the module answers with the manifest signed by the proposed secret, as in v1. signature-vectors.json has fixed inputs and outputs, including UTF-8 and reserved characters; a PHP 7.3 check for them is in contracts/scripts/vectors.php.
Revocation, rotation and pause
- Disconnect deletes the secrets and every shopper binding in the shop; every later request fails with
not_paired. It works while paused, because it only reduces access. - Rotation sends the next secret under a new
sec_Nkey ID, signed with the current one. The module answers with the new secret and accepts both for at most 10 minutes. The same key ID with a different value issecret_conflict. - Pause is the owner's switch in OpenCart. The module refuses everything except health, manifest and the connection operations with
paused(HTTP 423). It does not depend on Store Deputy behaving correctly. - In Store Deputy, revoking an agent credential, a grant or a team membership takes effect on the next call.
Capabilities and versions
The manifest lists capabilities with their own versions: catalog.read (Stage A), cart.read and cart.write (Stage B). price.base.write is reserved for STOR-5 and not offered. A capability the manifest does not declare is unavailable, whatever the module's code contains.
The path carries the major version (/v2), the manifest lists supported major.minor versions. A minor version only adds optional fields, operations and capabilities, and both sides ignore fields they do not know. Store Deputy supports the current and the previous major version.
Changes without duplicates
Every change carries X-SD-Operation-Key, stable per intent, not per attempt. Store Deputy derives it from the conversation and the model's tool-call ID, so a retry of the same tool call reuses the key and a new request from the person gets a new one.
- The module records the key with a hash of the request before it acts.
- The same key and the same request return the stored answer with
X-SD-Replayed: true. Nothing runs twice. - The same key with a different request is
operation_key_reusedand changes nothing. - After a timeout Store Deputy does not resend. It asks
GET /v2/operations:unknownmeans the request never arrived and may be sent again with the same key;completedgives the stored result;startedmeans the request died midway. - Every cart change also carries the cart revision it expects (next section). That is what makes resending a
startedchange safe: if the first attempt had applied, the revision changed and the resend comes back ascart_conflictinstead of adding the item twice.
Until the outcome is known, the agent gets outcome_uncertain and is told not to repeat the request.
Shoppers and the cart (Stage B)
- The storefront widget calls the module on the shop's own domain. The module reads the visitor from the real OpenCart session (guest or logged-in customer), never from a
customer_idsent by the browser. - The module creates a random
binding_ref, keeps its link to the OpenCart session on its side, and calls Store Deputy'sPOST /shopper-bindings, signed with the connection secret. It sends only: guest or customer, a pseudonym for a customer (derived with a key that stays in the shop), store, language, currency and expiry. No cookie, session ID, email or name. - Store Deputy checks the Frontend Agent entitlement, creates the binding and the conversation grant, and returns a chat token for the browser, valid at most 15 minutes and only at the chat gateway.
- A cart tool call resolves the grant to the binding, and Store Deputy calls the module with that
binding_ref. The module checks again that the binding is active and still matches the visitor's login state, then works on the visitor's real cart through OpenCart's own cart code. - Login, logout or a guest cart merging into a customer cart creates a new binding with
previous_binding_ref; the old one is revoked in the same step. A conversation can continue, but never with the previous shopper's authority. A stale binding fails withshopper_session_changed.
The cart revision is an opaque value that changes whenever anything affecting the cart's lines or totals changes, including edits in another tab. Every change must name the revision it expects; a mismatch returns cart_conflict with the current cart, never a silent overwrite. OpenCart computes prices, options, stock, discounts, tax and totals; the tools accept no price. Checkout, payment and orders are out of scope.
Errors
The module answers a refusal with a real HTTP status and { "error": { "code", "message", "retryable", "details" } }. Messages never contain secrets, SQL, file paths or personal data. The full table, with what the agent sees for each code, is contracts/errors.yaml. The ones that shape behaviour:
| Code | HTTP | Agent sees |
|---|---|---|
signature_invalid, timestamp_skew, unknown_key | 401 | store_refused; the operator is alerted |
replayed | 401 | Nothing; Store Deputy retries once with a fresh nonce |
not_paired | 401 | store_disconnected |
paused | 423 | store_paused |
capability_unavailable | 501 | capability_unavailable |
operation_key_reused | 409 | internal: a bug on our side |
operation_in_progress | 409 | outcome_uncertain |
binding_stale | 409 | shopper_session_changed |
binding_expired, binding_revoked | 410 | shopper_session_ended |
cart_conflict | 409 | cart_conflict with the current revision |
| No valid signature, timeout on a read | — | store_unreachable |
| Timeout on a change | — | outcome_uncertain, reconciled before anything is resent |
Settled here, and still to agree
This draft proposes: the REST paths with the logical path in a query parameter; the SD2 signatures bound to method, path, query and the request nonce; a required nonce store; rotation with a 10-minute overlap; operation keys per intent with ledger lookup; cart revisions on every change; tool names, arguments and the rule that none of them selects identity; the headers Agentfy sends and the grant model.
Needs agreement before implementation:
- Artem Belikov (module): the
?path=transport on real hosting; the nonce and operation tables on MyISAM; whether OpenCart's cart can be loaded for a stored session without the visitor's cookie (decision 002 requires proof on 3.0.3.5 and 3.0.5.1); how the cart revision is computed; the PHP check of the signature vectors, which has not been run, because PHP and Docker were not available where this draft was written. - Agentfy team: every row of what Agentfy does not do yet; in particular the exception to its "no context to MCP servers" policy.
- Founder: who opens an app conversation and therefore hands Agentfy the grant (Store Deputy proposed); which reads an
agentactor may do without a person; whether a shopper pseudonym is acceptable under the privacy rules the pilot will need. - Later: OAuth 2.1 client credentials instead of a static agent credential, if Agentfy adds it; storefront product search for the Frontend Agent, which no task covers yet.
Related: decision 002, Bridge Protocol v1, authority and recovery.