Skip to content

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.

FileWhat it fixes
contracts/module-rest/openapi.yamlThe module REST API, OpenAPI 3.1
contracts/module-rest/signature-vectors.jsonTest values for signatures, for PHP and TypeScript
contracts/store-deputy/module-callbacks.openapi.yamlWhat the module calls in Store Deputy (Stage B)
contracts/mcp/tools.yamlStore Deputy MCP tools, their schemas and examples
contracts/context/call-context.yamlHeaders Agentfy sends, grants, and the resolved context
contracts/errors.yamlEvery error code, its HTTP status and what the agent sees

Three authority boundaries ​

text
Agentfy runtime ──(1) agent credential + (2) conversation grant──▶ Store Deputy MCP
                                                                    │ policy, audit
                                                                    │
                     (3) connection signature, never (1) or (2) ────┘
                                                                    ▼
                                                     OpenCart module REST API
BoundaryProofIssued by, held byValid only atNever
Agent → Store DeputyAuthorization: Bearer sdak_…, one per provisioned agentStore Deputy; Agentfy keeps it as a team secretStore Deputy MCPForwarded to a shop, shown to the model, accepted as a person's authority
Person or shopperX-SD-Context: sdcg_…, one per conversationStore Deputy; Agentfy carries it per conversationStore Deputy MCP, for that agent and threadHolds permissions of its own; survives a revoked membership
Store Deputy → shopHMAC with the connection secret, both directionsStore Deputy and the module, at pairingThat one shop's moduleLeaves 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.

ToolDoes
store_statusWhich shop, reachable or not, paused or not, which tools work now
catalog_find_productsFind by SKU, model, EAN and other identifiers; returns every match, never picks one
catalog_get_productsCurrent details by product ID; missing IDs are listed
catalog_list_productsPage 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_id is refused with invalid_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 structuredContent and the same JSON as text. Failure sets isError and returns { "error": { "code", "message", "retryable", "details" } }. Agentfy does not read isError today, 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 ​

HeaderSet byStore 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-IdAgentfy runtimeEquals the agent the credential was issued to
X-SD-Context: sdcg_…Agentfy runtime, per conversationExists, not expired or revoked, issued for this agent and this thread
X-Agentfy-Thread-IdAgentfy runtimeEquals the thread the grant was issued for
X-Agentfy-Turn-IdAgentfy runtimeAudit only
X-Agentfy-Tool-Call-IdAgentfy runtimeBecomes the stable operation key of a change
X-Agentfy-Initiated-ByAgentfy runtimehuman, 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.

ActorWhenMay do
employeeA person opened the conversation in the app; grant names a Store Deputy userThe intersection of the grant, their live team role, the agent's profile, the entitlement and the shop's capabilities
agentA scheduled or agent-to-agent turn, no grantReads the owner allowed for the agent; never a change that needs a person's approval
shopperA visitor opened the storefront chat; grant names a shopper bindingTheir 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 AgentfyNeeded
MCP calls carry only team-shared static headers; by policy no agent, user or conversation context is sentOpt-in, per MCP server, the headers listed above, set by the runtime
No per-conversation secretStore a Store Deputy grant per thread, outside the prompt, and send it on that thread's calls
Visitor turns get no MCP tools at allAllow them for a server that is marked as frontend (Stage B)
isError is not inspectedTreat it as a failed tool step (recommended)
Cabinet threads are keyed by Agentfy userA separate thread per Store Deputy person, so their grants never meet
No service credential; creating agents and MCP servers needs a user sessionA 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:

text
GET https://shop.example/storedeputy.php?path=/v2/catalog/products&limit=100

HTTPS 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.

OperationStageWorks while paused
GET /v2/health — reachable, clockAyes
GET /v2/manifest — platform, limits, capabilities, warningsAyes
POST /v2/connection — pairingAyes
DELETE /v2/connection — disconnectAyes
POST /v2/connection/secrets — rotate the secretAyes
GET /v2/catalog/products — page through productsAno
POST /v2/catalog/product-lookups — by ID and by identifierAno
GET /v2/operations?keys=… — what happened to a changeAno
GET /v2/shopper-bindings/{ref}/cartBno
POST /v2/shopper-bindings/{ref}/cart/itemsBno
PATCH, DELETE /v2/shopper-bindings/{ref}/cart/items/{id}Bno

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.

text
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-MREQ and SD2-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_N key 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 is secret_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.

  1. The module records the key with a hash of the request before it acts.
  2. The same key and the same request return the stored answer with X-SD-Replayed: true. Nothing runs twice.
  3. The same key with a different request is operation_key_reused and changes nothing.
  4. After a timeout Store Deputy does not resend. It asks GET /v2/operations: unknown means the request never arrived and may be sent again with the same key; completed gives the stored result; started means the request died midway.
  5. Every cart change also carries the cart revision it expects (next section). That is what makes resending a started change safe: if the first attempt had applied, the revision changed and the resend comes back as cart_conflict instead 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) ​

  1. 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_id sent by the browser.
  2. The module creates a random binding_ref, keeps its link to the OpenCart session on its side, and calls Store Deputy's POST /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.
  3. 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.
  4. 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.
  5. 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 with shopper_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:

CodeHTTPAgent sees
signature_invalid, timestamp_skew, unknown_key401store_refused; the operator is alerted
replayed401Nothing; Store Deputy retries once with a fresh nonce
not_paired401store_disconnected
paused423store_paused
capability_unavailable501capability_unavailable
operation_key_reused409internal: a bug on our side
operation_in_progress409outcome_uncertain
binding_stale409shopper_session_changed
binding_expired, binding_revoked410shopper_session_ended
cart_conflict409cart_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 agent actor 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.