koh / sylius-acp
Agentic Commerce Protocol for Sylius: ACP carts with a buyer handoff, /.well-known/acp.json, and the ACP product feed published to agents (JSONL snapshot or Feed API upsert).
Requires
- php: ^8.3
- doctrine/orm: ^2.18 || ^3.5
- doctrine/persistence: ^3.1 || ^4.0
- koh/sylius-agent-contracts: ^1.5
- psr/cache: ^2.0 || ^3.0
- sylius/resource: ^1.14.2
- sylius/sylius: ^2.2.9 || ^2.3.0
- symfony/config: ^7.4 || ^8.0
- symfony/console: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/event-dispatcher: ^7.4 || ^8.0
- symfony/event-dispatcher-contracts: ^3.8.1
- symfony/http-client-contracts: ^3.8.1
- symfony/http-foundation: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- symfony/rate-limiter: ^7.4 || ^8.0
- symfony/routing: ^7.4 || ^8.0
- symfony/serializer: ^7.4 || ^8.0
Requires (Dev)
- doctrine/collections: ^2.6.0
- koh/dev-sylius: ^1.0
- opis/json-schema: ^2.6.2
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan: ^2.3.5
- phpstan/phpstan-doctrine: ^2.1.2
- phpunit/phpunit: ^11.5.57
- symfony/cache: ^7.4 || ^8.0
- symfony/http-client: ^7.4 || ^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- v1.3.5
- v1.3.4
- v1.3.3
- v1.3.2
- v1.3.1
- v1.3.0
- v1.2.1
- v1.2.0
- v1.1.48
- v1.1.47
- v1.1.46
- v1.1.45
- v1.1.44
- v1.1.43
- v1.1.42
- v1.1.41
- v1.1.40
- v1.1.39
- v1.1.38
- v1.1.37
- v1.1.36
- v1.1.35
- v1.1.34
- v1.1.33
- v1.1.32
- v1.1.31
- v1.1.30
- v1.1.29
- v1.1.28
- v1.1.27
- v1.1.26
- v1.1.25
- v1.1.24
- v1.1.23
- v1.1.22
- v1.1.21
- v1.1.20
- v1.1.19
- v1.1.18
- v1.1.17
- v1.1.16
- v1.1.15
- v1.1.14
- v1.1.13
- v1.1.12
- v1.1.11
- v1.1.10
- v1.1.9
- v1.1.8
- v1.1.7
- v1.1.6
- v1.1.5
- v1.1.4
- v1.1.3
- v1.1.2
- v1.1.1
- v1.1.0
- dev-main / 1.0.x-dev
- v1.0.0
This package is auto-updated.
Last update: 2026-11-11 20:53:03 UTC
README
koh/sylius-acp
Agentic Commerce Protocol (ACP) for Sylius: the
ACP cart service with a buyer handoff, a discovery document at
/.well-known/acp.json, publication of the ACP product feed to agents, and a
read-only catalogue endpoint for in-page tools.
- Carts (ACP 2026-04-17,
cartsservice) — an agent builds a cart with estimated totals;continue_urlhands it to the buyer, who checks out in the shop. Off by default. - Discovery document — protocol version, API base URL, transports, the
services on offer and the channel's currency and locales, per channel. Served
only when the shop offers an ACP service; otherwise
404, see below. - ACP product feed (ACP 2026-04-17,
schema.feed.json) —ProductandVariantobjects, published to the agent: as an offline snapshot (metadata.json+products.jsonl) or by upsert to the agent-hosted Feed API. Commands only; nothing runs until configured. See below. - Catalogue endpoint —
GET /api/agent/feed, a shop-specific JSON listing and name search used by the in-page tools ofkoh/sylius-webmcp. Not an ACP surface. - No purchase through the API — checkout is not declared unless the installation serves it itself.
- Registered as a skill — catalogue search shows up in the A2A card, the
agent-skills index and the MCP server card of the other
kohbundles without extra configuration.
Requires PHP 8.3 or later and Sylius 2.2 or 2.3.
Install
composer require koh/sylius-acp
Register the bundle and the shared contracts bundle it builds on:
// config/bundles.php return [ // … Koh\SyliusAgentContracts\KohSyliusAgentContractsBundle::class => ['all' => true], Koh\SyliusAcp\KohSyliusAcpBundle::class => ['all' => true], ];
Import the routes:
# config/routes/koh_agent.yaml koh_acp: resource: '@KohSyliusAcpBundle/config/routes.php'
Clear the cache and check that both endpoints answer:
bin/console cache:clear curl -s 'https://shop.example.com/api/agent/feed?limit=2' curl -si https://shop.example.com/.well-known/acp.json # 404 unless carts or checkout are on
Configure
Everything has a default; the file is only needed to change one:
# config/packages/koh_acp.yaml koh_acp: version: '2026-04-17' # ACP version announced in the discovery document api_base: '/api/agent' # advertised as api_base_url checkout: false # declare ACP checkout (served by the installation) age_restriction: ~ # minimum age on every catalogue item and cart carts: enabled: false bearer_token_hashes: '%env(ACP_CART_TOKEN_HASHES)%' max_line_items: 50 max_quantity: 99 rate_limit: 120 # requests per API key and interval rate_limit_interval: '1 minute' handoff_route: sylius_shop_cart_summary fallback_route: sylius_shop_homepage acp_feed: # the ACP product feed, see "ACP product feed" feed_id: ~ # issued by the agent channel: ~ # default: the only enabled channel locale: ~ # default: the channel's default locale target_country: ~ # default: the channel's only country, else billing country media_path_prefix: '/media/image/' batch_size: 100 # products per upsert request (1…1000) seller: name: ~ # default: shop billing company, else channel name links: [] # [{type: refund_policy, url: 'https://…', title: 'Refunds'}] api: # agent-hosted Feed API; off while base_url or bearer_token is empty base_url: '' bearer_token: '' timeout: 30 max_attempts: 3 feed: # the catalogue endpoint, not ACP default_limit: 100 max_limit: 500 max_page: 1000 # beyond it the endpoint answers an empty list
api_baseis announced, not routed. The catalogue route is fixed at/api/agent/feed; if you changeapi_base, add a route for{api_base}/feedpointing atKoh\SyliusAcp\Controller\ProductFeedController.checkoutstays off unless the installation really serves ACP checkout sessions ({api_base}/checkout_sessions); this bundle does not implement them. An agent that findscheckoutin the capabilities will try to buy.- ACP's
capabilities.servicesis a closed list per API version; in 2026-04-17 it is checkout, orders, delegate_payment and carts. The product feeds RFC proposes afeedsvalue, but 2026-04-17 does not admit it, so the discovery document never listsfeeds, whatever is configured. With neither carts nor checkout on, the shop offers no ACP service and/.well-known/acp.jsonanswers404— the discovery RFC's signal for "this seller does not support ACP". llms.txt and the api-catalog then do not announce it either; the catalogue endpoint stays announced. age_restrictionbelongs on every age-restricted catalogue. It is carried on every item of the catalogue endpoint and on every cart. The ACP product feed schema has no field for it, so the ACP feed cannot carry it; the cart and the shop's own checkout gate remain where it is enforced.
Carts
Why carts and not checkout: an ACP checkout session is completed with
payment_data for a payment handler and MUST create an order, and every handler
ACP 2026-04-17 defines goes through delegate_payment. A shop without a payment
provider for agents cannot offer that honestly. Carts carry no payment and are
never completed through the API; continue_url hands them to a person.
Routes (fixed, like the feed):
| Call | Route |
|---|---|
POST /api/agent/carts |
create |
GET /api/agent/carts/{id} |
retrieve |
PUT /api/agent/carts/{id} |
replace the lines |
POST /api/agent/carts/{id}/cancel |
cancel, answers the last state |
GET /acp-cart/{id} |
continue_url, opened by the buyer |
- Access. Every call needs
Authorization: Bearer <key>andAPI-Version: 2026-04-17. Issue each agent platform a random key and put only its SHA-256 digest intobearer_token_hashes(comma- or space-separated):printf %s "$KEY" | sha256sum. A cart is visible only to the key that created it. Requests are rate-limited per key. - Idempotency. Every POST needs an
Idempotency-Key; PUT honours one. The same key and body replays the stored answer (Idempotent-Replayed: true), another body is422 idempotency_conflict, a request still running is409 idempotency_in_flight. Answers are kept for 24 hours incache.app. - Pricing. An ACP cart is an ordinary Sylius cart (order in state
cart) without customer or session, priced by the shop's own order processors. The totals leave shipping out and say so; an included VAT is reported, not added. Sylius' expired-carts cleanup removes it;expires_atsays when. - Items.
line_items[].idis the ACP feed'sVariant.id(the variant code), alsoidon the catalogue endpoint. Unknown, disabled or foreign-channel items are dropped with anot_foundmessage; short stock stays in the cart with anout_of_stockmessage; nothing available is a422. Buyer details are not stored. - Age restriction. With
age_restrictionset, every cart carries anage_verification_requiredmessage: only the buyer can buy, in the shop. - Handoff.
continue_urladds the lines to the buyer's own cart throughsylius.cart_item_add, the event the shop's add-to-cart button fires. Every gate hung on that event (an age confirmation, for example) applies; if one refuses, nothing is added and the buyer lands onfallback_routewith a notice, and the link works again once the gate is satisfied. Checkout — address, age declaration, shipping, payment — is the shop's own. Opening the link twice does not add the items twice. The handoff isCartHandofffromkoh/sylius-agent-contracts, shared with the UCP permalink: a gate signals a refusal with an exception implementing itsCartGateRefusalInterface(anyRuntimeExceptionstill counts unlesskoh_agent.cart_handoff.runtime_exception_is_refusalis off). - Firewall.
/api/agent/must not start a session (see the agent-public firewall in the installation);/acp-cart/must run in the shop firewall so the buyer's session and login apply.
ACP product feed
In ACP the Product Feed API is hosted by the agent: the merchant publishes to it, and agents must not call feed endpoints on merchants (RFC "Product Feeds", §3.1). This bundle therefore serves no ACP feed route; it builds the feed and hands it to the agent in either of the RFC's two publication models.
Contents, per channel and locale: every enabled product of the channel with a
translation in the locale and at least one enabled variant priced in the
channel. Each line validates against Product of ACP 2026-04-17
(schema.feed.json), metadata.json against FeedMetadata.
| ACP field | Sylius source |
|---|---|
Product.id |
product code (the cart's product_id) |
Product.title |
product name in the locale |
Product.description.plain |
description, else short description; markup stripped, entities decoded |
Product.url |
{channel hostname}/{locale}/products/{slug} |
Product.media[] |
product images, main first: {hostname}{media_path_prefix}{path} |
Variant.id |
variant code — the id the cart service accepts as line_items[].id |
Variant.title |
variant name, else product name – option values |
Variant.url |
the product URL |
Variant.price |
channel price, minor units, channel base currency (EUR) |
Variant.list_price |
channel original price, only when above the price |
Variant.availability |
available and in_stock/out_of_stock, by the shop's stock check |
Variant.categories[] |
product taxons as paths below the root, main taxon first, taxonomy merchant |
Variant.condition |
["new"] |
Variant.variant_options[] |
option name and value in the locale, in the product's option order |
Variant.media[] |
images attached to the variant |
Variant.seller |
acp_feed.seller |
FeedMetadata |
id = feed_id, target_country, updated_at = time of export |
Not emitted, because Sylius has no source for them: barcodes,
unit_price, marketplace, Variant.description.
Offline full replacement
bin/console koh:acp:feed:export var/acp-feed --channel=WEB --locale=de_DE --feed-id=feed_8f3K2x
Writes var/acp-feed/metadata.json and var/acp-feed/products.jsonl (one
product per line). The catalogue is read in keyset batches and written as it is
read, so memory stays flat for large catalogues. Both files are written under a
temporary name and renamed into place. Uploading them is up to you: the agent's
onboarding says where. A full replacement removes from the agent every product
the snapshot does not contain.
API upsert
koh_acp: acp_feed: feed_id: '%env(default::ACP_FEED_ID)%' api: base_url: '%env(default::ACP_FEED_API_URL)%' # https://…; /feeds is appended bearer_token: '%env(default::ACP_FEED_API_TOKEN)%'
bin/console koh:acp:feed:create # once: POST /feeds, prints the feed id bin/console koh:acp:feed:push # PATCH /feeds/{id}/products, in batches bin/console koh:acp:feed:push --retire-missing
- Every request carries
Authorization: Bearer,API-Version(koh_acp.version) and JSON;POSTandPATCHcarry anIdempotency-Key, the same on every retry of one request and new on the next run. 429,5xxand broken connections are retried (max_attempts), honouringRetry-Afterup to 60 seconds. Errors in ACP's flat shape (type,code,message,param) are reported as such and end the command with exit code 1.pushfirst reads the feed (GET /feeds/{id}), so an unknown feed id stops it before anything is sent. Each product is sent whole, as the RFC recommends.- An upsert never deletes.
--retire-missingreads the agent's product set back and re-sends every product the shop no longer publishes with all variantsavailable: false,status: discontinued. - Unconfigured (
base_url,bearer_tokenandfeed_idempty),pushsays so and exits 0, so a schedule can be set up ahead of the configuration. A half configuration exits 1 and names what is missing.http://is refused.
Run push from the scheduler of your choice, e.g. hourly.
Output
GET /.well-known/acp.json with carts on (validates against
DiscoveryResponse of ACP 2026-04-17):
{
"protocol": {
"name": "acp",
"version": "2026-04-17",
"supported_versions": ["2026-04-17"],
"documentation_url": "https://agenticcommerce.dev"
},
"api_base_url": "https://shop.example.com/api/agent",
"transports": ["rest"],
"capabilities": {
"services": ["carts"],
"supported_currencies": ["eur"],
"supported_locales": ["en-US", "de-DE"]
}
}
GET /api/agent/feed?page=1&limit=2 — the catalogue endpoint. A
shop-specific shape for in-page tools, not ACP; agents that speak ACP get the
catalogue from the feed published to them:
{
"items": [
{
"id": "T_SHIRT_M",
"item_group_id": "T_SHIRT",
"title": "T-shirt, size M",
"description": "Organic cotton, regular fit.",
"link": "https://shop.example.com/en_US/products/t-shirt",
"availability": "in_stock",
"condition": "new",
"price": "1990",
"price_currency": "EUR"
}
],
"page": 1,
"limit": 2,
"count": 1
}
limit is clamped to 1…max_limit and page to at least 1; a page above
max_page answers an empty list without a database query. The page is cut in
the query (offset and row limit), so a crafted query string can neither pull
the whole catalogue in one request nor make the database read it. limit counts
products; count is the number of variants returned. Descriptions are plain
text, at most 500 characters.
q narrows the feed to products whose name contains the term (in the
channel's locale, enabled and in this channel only; at most 100 characters).
Without it the feed lists the channel's latest products.
POST /api/agent/carts with {"line_items": [{"id": "T_SHIRT_M", "quantity": 2}]}
(validates against Cart of ACP 2026-04-17, schema.cart.json):
{
"id": "acp_3f9c…_8a1b…",
"line_items": [
{
"id": "li_41",
"item": {"id": "T_SHIRT_M", "name": "T-shirt, size M", "unit_amount": 1990},
"quantity": 2,
"name": "T-shirt, size M",
"unit_amount": 1990,
"variant_id": "T_SHIRT_M",
"availability_status": "in_stock",
"totals": [
{"type": "subtotal", "display_text": "Subtotal", "amount": 3980},
{"type": "total", "display_text": "Total", "amount": 3980}
],
"product_id": "T_SHIRT"
}
],
"currency": "eur",
"totals": [
{"type": "items_base_amount", "display_text": "Items", "amount": 3980},
{"type": "tax", "display_text": "VAT (included)", "amount": 635},
{"type": "total", "display_text": "Estimated total (excluding shipping)", "amount": 3980}
],
"messages": [
{
"type": "info",
"content_type": "plain",
"content": "Totals are estimates without shipping. The buyer completes the purchase in the shop, where shipping, payment and the final total are settled: hand them continue_url."
}
],
"continue_url": "https://shop.example.com/acp-cart/acp_3f9c…_8a1b…",
"expires_at": "2026-11-07T10:00:00Z"
}
The discovery document is cached for an hour, the catalogue endpoint for 15
minutes; cart answers are no-store.
Decommission
Remove the composer require, the config/bundles.php entry, the route import,
config/packages/koh_acp.yaml and any schedule running koh:acp:feed:push. No database table and no cache pool of its
own; agent carts are Sylius carts and expire with them.
Security
Report vulnerabilities privately — see SECURITY.md. Advisories are published as CSAF 2.0 at https://ole-hartwig.eu/.well-known/csaf/.
The family threat model, covering this package, is at https://github.com/ohartwig/sylius-agent-ready/blob/main/docs/threat-model.md.
Licence
MIT — see LICENSE.