A2A (Agent2Agent) is the open protocol, now hosted by the Linux Foundation, that lets agents built on different frameworks call each other. Version 1.0.0 was released on 12 March 2026. The official SDKs cover Python, JavaScript, Java, Go, .NET and Rust. PHP is not on that list, and the PHP packages I found on Packagist in September 2026 target protocol 0.3.0.

So I wrote a2a-php-sdk and a2a-symfony-bundle. A protocol SDK is only worth something if it talks to implementations written by other people, so this article is not about the SDK’s internals. It is about one reproducible run against the reference implementation:

DirectionClientServer
PHP → Pythona2a-php-sdkthe official hello-world sample on a2a-sdk
Python → Symfonythe official a2a-sdk clienta Symfony 8 application with the bundle

Everything below comes from a2a-php-interop-demo. make demo builds it, runs both directions and exits 0. The full output of the run is committed as docs/run-2026-09-15.log.

The versions

ComponentVersion
PHP8.4.23
vbcherepanov/a2a-php-sdk1.0.0
vbcherepanov/a2a-symfony-bundle1.0.2
symfony/framework-bundle8.0.15
Python3.13.15
a2a-sdk1.1.0
a2a-samplescommit 6603ba3

The Python agent is not a copy. The Dockerfile downloads __main__.py, agent_executor.py and requirements.txt from that exact a2a-samples commit and runs them unmodified.

One constraint shaped the whole setup

The official sample binds to 127.0.0.1:9999 and writes the same address into its Agent Card. In Docker that address is private to the container. Instead of patching the reference implementation, every service in compose.yaml joins the Python agent’s network namespace:

symfony-agent:
  build: ./symfony-agent
  network_mode: "service:python-agent"

Now 127.0.0.1:9999 means the Python agent and 127.0.0.1:8000 means the Symfony agent, for every container.

Direction 1: PHP calls the official Python agent

The client discovers the agent, picks the JSON-RPC interface from the card, and sends the same message twice — once blocking, once streaming:

$http = HttpClient::create();
$options = new CallOptions(timeoutSeconds: 30.0);

$card = (new Discovery($http, allowPrivateNetwork: true))->discover($baseUrl, $options);

$client = new Client(new HttpTransport($http, $endpoint, jsonRpc: true), $options);
echo $client->sendMessage($request())->serializeToJsonString();

foreach ($client->sendStreamingMessage($request()) as $event) {
    echo $event->serializeToJsonString(), "\n";
}

allowPrivateNetwork: true is deliberate. By default the SDK’s discovery refuses private-network addresses, because an Agent Card is data from someone else. For a local demo that protection has to be switched off explicitly — and it stays on in production code.

The streaming call produced four events, in order:

task          TASK_STATE_SUBMITTED
statusUpdate  TASK_STATE_WORKING    "Processing request..."
artifactUpdate                      "Hello, World! I have received your request (Hi from a2a-php-sdk)"
statusUpdate  TASK_STATE_COMPLETED  "Request is completed!"

Those strings come from the Python sample’s executor. The PHP side decoded every event into the generated protobuf classes without a single custom mapping.

Direction 2: the official Python client calls Symfony

On the Symfony side the agent is four files on top of symfony/skeleton and composer require vbcherepanov/a2a-symfony-bundle.

The executor — one class that yields an artifact and a final status:

final class EchoExecutor implements Executor
{
    public function execute(SendMessageRequest $request, Task $task, CallContext $context): iterable
    {
        // collect the text parts of the incoming message into $text
        yield (new StreamResponse())->setArtifactUpdate(/* "Hello from a Symfony agent. You said: $text" */);
        yield (new StreamResponse())->setStatusUpdate(/* TASK_STATE_COMPLETED */);
    }
}

The bundle config and the route import:

# config/packages/a2a.yaml
a2a:
    executor: App\Agent\EchoExecutor
    card_file: '%kernel.project_dir%/config/agent-card.json'
    public_url: '%env(A2A_PUBLIC_URL)%'
    auth:
        tokens:
            python-client: '%env(A2A_DEMO_TOKEN)%'

# config/routes/a2a.yaml
a2a:
    resource: .
    type: a2a

The Agent Card carries only identity, capabilities and skills. The bundle fills in the interfaces from public_url and the enabled transports.

The Python client is the official a2a-sdk API, with the bearer token passed through httpx:

async with httpx.AsyncClient(headers={'Authorization': f'Bearer {TOKEN}'}) as http:
    client = await create_client(
        agent=card,
        client_config=ClientConfig(streaming=True, httpx_client=http),
    )
    async for chunk in client.send_message(request):
        print(chunk)

This is what the official client printed about the Symfony agent and its reply:

Name        : Symfony Echo Agent
  [0] http://127.0.0.1:8000/a2a/rpc  (JSONRPC 1.0)
  [1] http://127.0.0.1:8000/a2a  (HTTP+JSON 1.0)
Streaming           : True

artifact_update  "Hello from a Symfony agent. You said: Hi from the official A2A Python SDK"
status_update    TASK_STATE_COMPLETED

Then the same client ran with a wrong token:

a2a.client.errors.A2AClientError: HTTP Error 401: Client error '401 Unauthorized'
for url 'http://127.0.0.1:8000/a2a/rpc'

The bundle has no anonymous mode. An A2A endpoint executes work on behalf of the caller, so the container refuses to compile unless you configure a token map, a Symfony access-token handler or your own authenticator.

Two bugs that only a real install could find

Preparing this demo, I installed the bundle the way a stranger would — composer require into a fresh symfony/skeleton — and it broke twice.

1.0.0: Symfony Flex registers a bundle automatically, even without a recipe, and then runs cache:clear. The bundle’s configuration marked card_file, executor and public_url as required, so the install failed with The child config “card_file” under “a2a” must be configured before anyone had a chance to configure anything. The bundle’s CI already installed the package into a fresh project, but that check did not go through Symfony Flex. Fixed in 1.0.1: without an a2a section the bundle registers nothing, and CI now installs through the skeleton.

1.0.1: with config/routes/a2a.yaml present but no configuration yet, routing failed with Cannot load resource ”.” Make sure there is a loader supporting the “a2a” type. That matters because a Flex recipe drops the routes file in at install time. Fixed in 1.0.2: the route loader is always registered and returns an empty collection until the bundle is configured.

Neither bug was visible to unit tests. Both were visible within a minute of installing the package the way the documentation tells users to.

What this run proves, and what it does not

It proves that, for the versions above, the PHP SDK and the Symfony bundle interoperate with the reference Python implementation over JSON-RPC, for Agent Card discovery, blocking SendMessage and streaming SendMessage, including bearer authentication on the Symfony side.

It does not cover the REST and gRPC bindings, push notifications or the extended Agent Card against Python — the SDK has its own tests for those, but not in this demo. The PHP built-in server is for the demo only.

Conformance is a separate question, answered by the official test kit rather than by one sample. The SDK runs the official a2a-tck 1.0.0 against all three transports: 244 passed, 3 failed, 18 skipped. The three failures are one requirement, CORE-SEND-003, which scores the correct error response as a failure because its definition omits the expected error; that is tracked as a2a-tck#202, and with the fix from PR #203 the run is 247 passed, 18 skipped. That is not a certification, and I don’t present it as one.

Try it

git clone https://github.com/vbcherepanov/a2a-php-interop-demo.git
cd a2a-php-interop-demo
make demo
make down

If it fails on your machine, open an issue in the demo repository with the output of make demo — a failing interop run is exactly the kind of report these packages need.