koh / sylius-a2a
An A2A 1.0 agent for Sylius: a JSON-RPC endpoint with deterministic catalogue skills and the Agent Card at /.well-known/agent-card.json.
Requires
- php: ^8.3
- doctrine/orm: ^2.18 || ^3.5
- koh/sylius-agent-contracts: ^1.1
- sylius/sylius: ^2.2.9 || ^2.3.0
- symfony/config: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- 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
Requires (Dev)
- koh/dev-sylius: ^1.0
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan: ^2.3.5
- phpunit/phpunit: ^11.5.57
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- v1.3.1
- v1.3.0
- v1.2.0
- 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-05 11:35:38 UTC
README
koh/sylius-a2a
An A2A agent for a Sylius shop: a JSON-RPC
endpoint at /api/agent/a2a speaking A2A 1.0 (spec release v1.0.1), two
catalogue skills behind it, and the Agent Card at
/.well-known/agent-card.json that names both. No language model and no API
key: every skill is a deterministic, read-only catalogue lookup.
Install
composer require koh/sylius-a2a
// config/bundles.php Koh\SyliusA2a\KohSyliusA2aBundle::class => ['all' => true],
# config/routes/koh_agent.yaml koh_a2a: resource: '@KohSyliusA2aBundle/config/routes.php'
The endpoint sits under /api/agent/, the agent API prefix. Keep that prefix
out of the session-bearing shop firewall, as for the other agent documents —
otherwise every agent call starts a shop session:
# config/packages/security.yaml security: firewalls: koh_agent_public: pattern: '^/(\.well-known/(?!oauth)|api/agent/)' security: false
Configure
Everything has a default; a shop that configures nothing serves the endpoint and the card.
# config/packages/koh_a2a.yaml koh_a2a: version: '1.0.0' # the agent's own version, on the card description: 'Tobacco, shipped from Germany.' # defaults to a sentence with the channel name age_restriction: 18 # carried on every product returned; default: not carried endpoint: enabled: true # false: /api/agent/a2a answers 404 rate_limit: 60 # requests per client address ... rate_interval: '1 minute' # ... and interval (sliding window) max_request_bytes: 65536 # larger bodies: HTTP 413, before parsing task_ttl: 86400 # seconds a task id stays retrievable interfaces: [] # further interfaces serving this same agent
The endpoint
JSON-RPC 2.0 over POST /api/agent/a2a, Content-Type: application/json, one
call per request (batches are refused). Clients send A2A-Version: 1.0 (a
header, or the A2A-Version query parameter); the patch level is ignored. A
request without one is a 0.3 client by the spec's rule and gets
VersionNotSupportedError (-32009).
| Method | Answer |
|---|---|
SendMessage |
A Task in TASK_STATE_COMPLETED with the result as an artifact |
GetTask |
The same task, rebuilt from its id |
CancelTask |
TaskNotCancelableError (-32002): every task is finished when it is created |
ListTasks |
An empty list: tasks belong to no account, so none is visible to anyone |
SendStreamingMessage, SubscribeToTask, GetExtendedAgentCard |
UnsupportedOperationError (-32004) |
| push-notification configuration | PushNotificationNotSupportedError (-32003) |
Errors carry google.rpc.ErrorInfo / google.rpc.BadRequest details in
error.data. Protocol errors come back with HTTP 200; only an oversized body
(413) and a client over its rate limit (429 with Retry-After) keep an HTTP
status.
Skills
product-search— products whose name contains the term, as the storefront search finds them: enabled, assigned to the channel, translated into the locale. Send the term as a text part, or a data part{"query": "...", "limit": 10, "offset": 0, "locale": "en_GB"}(limit up to 25, paging within the first 200 results). When a whole sentence matches no name, its words are tried one by one and the result says which matched.product-detail— one product by code or slug: a data part{"skill": "product-detail", "code": "..."}(or"slug").
Each product comes with its price (the channel price, gross in a shop that
prices with tax included, in minor units, currency and formatted for the
locale), the struck-through original price where there is one, availability as
the storefront's inventory checker reports it, and the product page URL. The
artifact holds an application/json data part and a text/plain rendering;
configuration.acceptedOutputModes narrows that. A message picks its skill
with a skill field (data part or message metadata); without one, a code or
slug means product detail and anything else a search.
Tasks without storage
A task id is the task: skill, arguments, channel, locale, context and creation
time, signed with the kernel secret. GetTask checks the signature and runs
the lookup again, so any replica answers and nothing is written; a forged,
tampered or expired id is not found. The artifacts therefore show the
catalogue as it is when the task is fetched. No conversation state is kept: a
message to an existing task is refused (the task is finished), and history is
not returned.
Discovery
The card lists supportedInterfaces with this endpoint first, then any
configured interfaces, and the skills the endpoint runs — not the shared
skill registry, which also holds skills other interfaces serve. While the card
is served, the endpoint is announced to the discovery registry (llms.txt,
the readiness check) and the JSON-RPC endpoint to the RFC 9727 api-catalog
with the card as its service-desc, which also puts it into the Link headers.
With the endpoint disabled and no interfaces, the card answers 404 and
nothing announces it.
Do not list the MCP endpoint under interfaces. It speaks MCP, not A2A, and
needs an admin-delegated token. The binding is limited to the three A2A core
bindings, so MCP is rejected at container build. transport_path is
deprecated and ignored.
Security
Public, unauthenticated, read-only. Nothing reaches an agent that an anonymous
storefront visitor could not see. Requests are rate-limited per client address
(Symfony RateLimiter over cache.app), the body is size-capped before parsing
and depth-capped while parsing, and messages are limited to 20 parts and 2000
characters of text. The services keep no per-request state, which matters
under FrankenPHP's worker mode.
Decommission
Remove the composer require, the bundle entry, the route import and
config/packages/koh_a2a.yaml. No table and no cache pool; the rate limiter's
entries in cache.app expire on their own.
Reporting vulnerabilities
Report vulnerabilities privately — see SECURITY.md. Advisories are published as CSAF 2.0 at https://ole-hartwig.eu/.well-known/csaf/.
Licence
MIT — see LICENSE.