Skip to content

Your first agent

An A2A agent has three parts:

  1. An AgentExecutor: your logic. It reads the incoming message and publishes events: a task, status changes and artifacts.
  2. An Agent Card: the JSON that tells clients who you are, what you can do and where to reach you. It is served at /.well-known/agent-card.json.
  3. A request handler and routes: the SDK part that speaks JSON-RPC and HTTP+JSON, stores tasks and streams events.

The code on this page is the SDK's examples/hello-world, a port of the Python SDK's hello_world_agent.py. CI runs it, and the official Python client talks to it over both transports.

1. The executor

<?php

declare(strict_types=1);

use A2A\Server\AgentExecution\AgentExecutor;
use A2A\Server\AgentExecution\RequestContext;
use A2A\Server\Events\EventQueue;
use A2A\Server\Tasks\TaskUpdater;
use A2A\Types\Part;
use A2A\Types\Task;
use A2A\Types\TaskState;
use A2A\Types\TaskStatus;

/**
 * The hello-world agent, ported line for line from the A2A Python SDK's
 * samples/hello_world_agent.py (SampleAgentExecutor).
 */
final class HelloExecutor implements AgentExecutor
{
    public function execute(RequestContext $context, EventQueue $eventQueue): void
    {
        $userMessage = $context->message();
        $taskId = $context->taskId();
        $contextId = $context->contextId();
        if ($userMessage === null || $taskId === null || $contextId === null) {
            return;
        }

        $eventQueue->enqueueEvent(new Task([
            'id' => $taskId,
            'context_id' => $contextId,
            'status' => new TaskStatus(['state' => TaskState::TASK_STATE_SUBMITTED]),
            'history' => [$userMessage],
        ]));

        $updater = new TaskUpdater($eventQueue, $taskId, $contextId);
        $updater->startWork($updater->newAgentMessage([new Part(['text' => 'Processing your question...'])]));

        $reply = $this->parseInput($context->getUserInput());
        sleep(1);

        // Python tracks running tasks in a set; a PHP request can be
        // cancelled from another process, so ask the context instead.
        if ($context->isCancelled()) {
            return;
        }

        $updater->addArtifact([new Part(['text' => $reply])], name: 'response', lastChunk: true);
        $updater->complete();
    }

    public function cancel(RequestContext $context, EventQueue $eventQueue): void
    {
        (new TaskUpdater($eventQueue, (string) $context->taskId(), (string) $context->contextId()))->cancel();
    }

    private function parseInput(string $query): string
    {
        if ($query === '') {
            return 'Hello! Please provide a message for me to respond to.';
        }
        $q = strtolower($query);
        if (str_contains($q, 'hello') || str_contains($q, 'hi')) {
            return 'Hello World! Nice to meet you!';
        }
        if (str_contains($q, 'how are you')) {
            return "I'm doing great! Thanks for asking. How can I help you today?";
        }
        if (str_contains($q, 'goodbye') || str_contains($q, 'bye')) {
            return 'Goodbye! Have a wonderful day!';
        }

        return "Hello World! You said: '{$query}'. Thanks for your message!";
    }
}
php artisan a2a:make-executor Hello

That writes app/A2A/HelloExecutor.php. With a reply filled in:

// What `php artisan a2a:make-executor Hello` generates, with a reply filled in.
final class HelloExecutor implements AgentExecutor
{
    public function execute(RequestContext $context, EventQueue $eventQueue): void
    {
        $message = $context->message();
        if ($context->currentTask() === null && $message !== null) {
            $eventQueue->enqueueEvent(ProtoHelpers::newTaskFromUserMessage($message));
        }

        $updater = new TaskUpdater($eventQueue, (string) $context->taskId(), (string) $context->contextId());
        $updater->startWork();
        $updater->addArtifact([new Part(['text' => 'Hello, ' . $context->getUserInput()])], name: 'response', lastChunk: true);
        $updater->complete();
    }

    public function cancel(RequestContext $context, EventQueue $eventQueue): void
    {
        (new TaskUpdater($eventQueue, (string) $context->taskId(), (string) $context->contextId()))->cancel();
    }
}

The executor is resolved from the container, so its constructor can take your services.

class SampleAgentExecutor(AgentExecutor):
    async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
        await event_queue.enqueue_event(Task(
            id=context.task_id, context_id=context.context_id,
            status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED),
            history=[context.message],
        ))
        updater = TaskUpdater(event_queue=event_queue, task_id=context.task_id, context_id=context.context_id)
        await updater.start_work(message=updater.new_agent_message(parts=[Part(text='Processing your question...')]))

        reply = self._parse_input(context.get_user_input())
        await asyncio.sleep(1)

        await updater.add_artifact(parts=[Part(text=reply)], name='response', last_chunk=True)
        await updater.complete()

    async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
        await TaskUpdater(event_queue=event_queue, task_id=context.task_id, context_id=context.context_id).cancel()

Each enqueueEvent() and each TaskUpdater call is saved and streamed to clients at once, while execute() keeps running. Running agents in PHP explains how.

2. The Agent Card

$agentCard = new AgentCard([
    'name' => 'Sample Agent',
    'description' => 'A sample agent to test the stream functionality.',
    'provider' => new AgentProvider(['organization' => 'A2A Samples', 'url' => 'https://example.com']),
    'version' => '1.0.0',
    'capabilities' => new AgentCapabilities(['streaming' => true, 'push_notifications' => false]),
    'default_input_modes' => ['text'],
    'default_output_modes' => ['text', 'task-status'],
    'skills' => [new AgentSkill([
        'id' => 'sample_agent',
        'name' => 'Sample Agent',
        'description' => 'Say hi.',
        'tags' => ['sample'],
        'examples' => ['hi'],
        'input_modes' => ['text'],
        'output_modes' => ['text', 'task-status'],
    ])],
    'supported_interfaces' => [
        new AgentInterface(['protocol_binding' => 'JSONRPC', 'protocol_version' => '1.0', 'url' => $baseUrl . '/a2a/jsonrpc']),
        new AgentInterface(['protocol_binding' => 'HTTP+JSON', 'protocol_version' => '1.0', 'url' => $baseUrl . '/a2a/rest']),
    ],
]);
final class HelloAgentCard implements AgentCardProvider
{
    public function agentCard(): AgentCard
    {
        // No supported_interfaces: Route::a2a() fills them in from your routes.
        return new AgentCard([
            'name' => 'Hello Agent',
            'description' => 'Says hello.',
            'version' => '1.0.0',
            'capabilities' => new AgentCapabilities(['streaming' => true]),
            'default_input_modes' => ['text/plain'],
            'default_output_modes' => ['text/plain'],
            'skills' => [new AgentSkill([
                'id' => 'hello',
                'name' => 'Hello',
                'description' => 'Say hi.',
                'tags' => ['demo'],
            ])],
        ]);
    }
}

You can also put the card in config/a2a.php as an array.

agent_card = AgentCard(
    name='Sample Agent',
    description='A sample agent to test the stream functionality.',
    provider=AgentProvider(organization='A2A Samples', url='https://example.com'),
    version='1.0.0',
    capabilities=AgentCapabilities(streaming=True, push_notifications=False),
    default_input_modes=['text'],
    default_output_modes=['text', 'task-status'],
    skills=[AgentSkill(id='sample_agent', name='Sample Agent', description='Say hi.',
                       tags=['sample'], examples=['hi'],
                       input_modes=['text'], output_modes=['text', 'task-status'])],
    supported_interfaces=[
        AgentInterface(protocol_binding='JSONRPC', protocol_version='1.0', url=f'http://{host}:{port}/a2a/jsonrpc'),
        AgentInterface(protocol_binding='HTTP+JSON', protocol_version='1.0', url=f'http://{host}:{port}/a2a/rest'),
    ],
)

3. Serve it

$pdo = new PDO('sqlite:' . (getenv('A2A_DB') ?: sys_get_temp_dir() . '/a2a-php-hello-world.sqlite'));
$handler = new DefaultRequestHandler(
    agentExecutor: new HelloExecutor(),
    taskStore: new PdoTaskStore($pdo),
    agentCard: $agentCard,
    queueManager: new PdoQueueManager($pdo),
);

$router = Routes::router($handler, $agentCard, jsonRpcPath: '/a2a/jsonrpc', restPrefix: '/a2a/rest');
(new ResponseEmitter($handler))->emit($router->handle($request));

Run it with PHP's built-in server:

PHP_CLI_SERVER_WORKERS=4 php -S 127.0.0.1:41241 examples/hello-world/server.php
Route::a2a('/a2a', agentCard: HelloAgentCard::class, executor: HelloExecutor::class);

That one line mounts the Agent Card at /.well-known/agent-card.json (and /a2a/.well-known/agent-card.json), JSON-RPC at /a2a/jsonrpc and HTTP+JSON under /a2a/rest, and fills the card's interface URLs in from those routes. The Laravel guide covers auth, queued execution and storage.

request_handler = DefaultRequestHandler(
    agent_executor=SampleAgentExecutor(),
    task_store=InMemoryTaskStore(),
    agent_card=agent_card,
)
app = FastAPI()
add_a2a_routes_to_fastapi(
    app,
    agent_card_routes=create_agent_card_routes(agent_card=agent_card),
    jsonrpc_routes=create_jsonrpc_routes(request_handler=request_handler, rpc_url='/a2a/jsonrpc'),
    rest_routes=create_rest_routes(request_handler=request_handler, path_prefix='/a2a/rest'),
)

Use a shared store under PHP-FPM or php -S

Python keeps tasks in memory because one long-running process serves every request. In PHP each request is a separate process, so InMemoryTaskStore starts empty every time. Use PdoTaskStore and PdoQueueManager on the same database (SQLite is fine for one machine). The in-memory versions are for tests and long-running servers such as RoadRunner or Swoole.

4. Try it

php examples/call-an-agent.php http://127.0.0.1:41241 hello
task 5d0c…
status TASK_STATE_WORKING
artifact Hello World! Nice to meet you!
status TASK_STATE_COMPLETED

The same server answers the official Python SDK client, the A2A test kit, and any other A2A client. See Conformance.

The pieces you can swap

Piece Default Other options
TaskStore none (you choose) PdoTaskStore (SQLite, PostgreSQL, MySQL), InMemoryTaskStore, your own
QueueManager InMemoryQueueManager PdoQueueManager; the Laravel bridge adds RedisQueueManager (Redis Streams)
TaskRunner InlineTaskRunner (runs in the request) the Laravel bridge adds QueuedTaskRunner (runs on a queue worker)
ServerCallContextBuilder reads the a2a.user request attribute your own, to plug in authentication
PushNotificationConfigStore none (push is off) InMemoryPushNotificationConfigStore; sending arrives in phase 5