Your first agent¶
An A2A agent has three parts:
- An
AgentExecutor: your logic. It reads the incoming message and publishes events: a task, status changes and artifacts. - 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. - 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!";
}
}
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:
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¶
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 |