As an agent developer, one of the key distinctions to make when implementing the Agent-to-Agent (A2A) protocol is when to use a Message versus a Task object. While both are fundamental to the A2A protocol, they serve distinct purposes. This guide will walk you through the nuances of each and provide a clear mental thought process for when to use them in your agent’s design.

At its core, the A2A protocol empowers agents to communicate and collaborate. Messages are the lifeblood of this communication. They are perfect for:

  • Discovery: When a user interacts with your agent, they might want to know its capabilities. This includes chit-chat where the user is trying to figure out what they want. A simple message exchange is the most natural way to handle this.

  • Quick Questions and Clarifications: If the client needs a simple piece of information to proceed which can be fetched quickly.

On the other hand, a Task object brings structure and clarity to more complex interactions. Tasks are preferable for:

  • Goal-Oriented Actions: When a user has a clear intent to accomplish something, a Task provides a dedicated space to track & collaborate over that goal.

  • Long-Running Operations: For processes that might take time, a Task allows for asynchronous tracking, so the user can check on the status without being blocked.

  • Disambiguation: Tasks prevent confusion when multiple goals are being pursued in parallel within the same conversation.

Message-only approach

It is possible for an agent to just reply with Message objects. Here’s how a conversation might unfold using only messages:

User: What can you do?

Booking Agent: I can book flights, hotels, and car rentals.

User: I want to book a flight from NY to SF.

Booking Agent: Sure, I can help with that.

User: I also need to book a hotel room in Lake Tahoe.

Booking Agent: I am on it.

User: Book it for the 19th of this month.

Herein lies the ambiguity. Is the user referring to the flight or the hotel booking? The agent now has to infer the user’s intent. While the agent could ask for clarification, this creates a clunky user experience, especially if it happens repeatedly.

This ambiguity isn’t limited to the user’s messages. Consider the agent’s perspective:

Booking Agent: How many travelers to book for?

Is the agent asking about the flight or the hotel? The user is now the one who has to do the guesswork.

This problem is magnified as we increase the number of goals being parallely collaborated upon. For a parent agent orchestrating multiple decomposed-goals, this ambiguity can quickly become an issue.

The Task object to the rescue

This is where the Task object shines. By creating a dedicated Task for each goal, we introduce a clear and unambiguous way to collaborate. Each Task has a unique ID, so both the user and the agent can refer to a specific goal without confusion. All interaction pertaining to that user goal is within the context of that Task.

What if we only used Tasks?

One might be tempted to use Task objects for all interactions. For example, every response to a user could be a COMPLETED task (the full JSON structure is omitted for brevity).

User: Message(text: "What can you do?")

Booking Agent: Task(id: 456, status: COMPLETED, message: "I can book flights, hotels, etc.")

While this is technically possible, it introduces its own set of problems:

  • Task Proliferation: Your system will be flooded with COMPLETED task objects for even the most trivial exchanges.

  • Difficult History Review: When a user wants to review their past interactions, it becomes difficult to distinguish between simple chit-chat and meaningful goal-oriented tasks.

The best of both worlds: A hybrid approach

The most effective approach is to use both Messages and Tasks in a way that plays to their respective strengths.

  • Use Messages for:

    • Initial greetings and capability discovery.

    • To return responses for quick internal actions.

    • General conversation that doesn’t express a clear intent to perform an action.

  • Use Tasks when:

    • A user expresses a clear intent to achieve a goal (e.g., “Book a flight,” “Find a hotel”).

    • The goal completion can require the agent to collaborate over multiple turns.

    • The operation is long-running and requires asynchronous tracking.

Benefits of the hybrid approach

This approach offers several advantages:

  • Clarity and Disambiguation: Task IDs eliminate confusion when multiple goals are being pursued in parallel.

  • Simplified Client Design: Clients have a dedicated getTask API for fetching the state of a Task, simplifying the process of tracking progress.

  • Simpler Client UIs: Users can easily view all their active and completed tasks, providing a clear overview of their interactions with the agent. When an agent needs more information, that request can be displayed within the context of the relevant task and all associated previous messages, making it easy for the user to understand what’s being asked.

Let’s revisit our booking agent scenario, this time with Tasks (the full JSON structure is omitted for brevity):

User: Message(text: "I want to book a flight from NY to SF.")

Booking Agent: Task(id: "flight-123", status: "working", message: "Sure, I can help with that.")

User: Message(text: "I also need to book a hotel room in Lake Tahoe.")

Booking Agent: Task(id: "hotel-456", status: "working", message: "I am on it.")

User: Message(task_id: "hotel-456", text: "Book it for the 19th of this month.")

Booking Agent: Task(id: "flight-123", status: "input-required", message: "How many travellers?")

Notice how the task_id field in the Message object clearly indicates which task the user is referring to. Similarly, when the booking agent asks for more input, it specifies the task_id. The conversation is now unambiguous and easy to follow for both the user and the agent.

Advanced: Dynamically upgrade Message to a Task

As previously stated, messages can be used to return responses from fast internal tools or APIs. Like in the case of a Booking Agent, assuming it is determined that check-flight-status API is fast enough, user queries for flight status can be responded to as a Message.

Though it is not always the case that the internal API returns within the expected timeout of the user request. In such cases, the agent can internally keep a timer for a pre-defined timeout. If the internal API comes back with the response within that timer, then the response is piped back to the user as a Message.

Otherwise, a new Task is created in WORKING state. Once the internal API response is available, then the task is populated with the results and marked as COMPLETED.

Code example walkthrough

Link to the Colab notebook.

Setup :

Install dependencies
%pip install google-cloud-aiplatform httpx "a2a-sdk" --quiet
%pip install --upgrade --quiet  "google-adk"
Imports & authenticate
import json
import uuid
import pprint

from collections.abc import AsyncIterable
from typing import Any, Optional

from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.apps import A2AStarletteApplication
from a2a.server.events import EventQueue

from pydantic import BaseModel
from enum import Enum

from a2a.types import (
    Part,
    Task,
    TaskState,
    TextPart,
    MessageSendParams,
    Role,
    Message,
)

from a2a.utils import (
    new_agent_parts_message,
    new_agent_text_message,
    new_task,
)

from a2a.server.request_handlers.default_request_handler import (
    DefaultRequestHandler,
)
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater

from a2a.utils.errors import MethodNotImplementedError

# Build agent with adk
from google.adk.events import Event
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.tools.tool_context import ToolContext
from google.adk.agents.llm_agent import LlmAgent

# Evaluate agent
from google.cloud import aiplatform
from google.genai import types

pp = pprint.PrettyPrinter(indent=2, width=120)

from google.colab import auth

try:
    auth.authenticate_user()
    print('Colab user authenticated.')
except Exception as e:
    print(
        f'Not in a Colab environment or auth failed: {e}. Assuming local gcloud auth.'
    )

import os

if not PROJECT_ID:
    raise ValueError('Please set your PROJECT_ID.')
os.environ['GOOGLE_CLOUD_PROJECT'] = PROJECT_ID
os.environ['GOOGLE_CLOUD_LOCATION'] = LOCATION
os.environ['GOOGLE_GENAI_USE_VERTEXAI'] = 'True'

aiplatform.init(project=PROJECT_ID, location=LOCATION)

Router

The idea is to define a router agent, whose sole job is to look at user queries and figure out if it’s chit-chat vs. it maps to set of pre-defined actions. Let’s take the booking agent example.

Below is the definition of a router agent. It determines the next step and associated user message. Since this agent has access to user conversation, it can summarise that as input to the next step as well.

class RouterActionType(str, Enum):
    NONE = "NONE"
    BOOK_FLIGHT = "BOOK_FLIGHT"
    BOOK_HOTEL = "BOOK_HOTEL"

class RouterOutput(BaseModel):
  message: str
  next_step: RouterActionType
  next_step_input: Optional[str] = None

We defined the instructions for the ADK agent, and used ADK output schema configuration to restrict the output.

class RouterAgent:
    """An agent that determines whether to call internal API vs chit-chat"""

    def __init__(self) -> None:
        self._agent = self._build_agent()
        self._user_id = 'remote_agent'
        self._runner = Runner(
            app_name=self._agent.name,
            agent=self._agent,
            session_service=InMemorySessionService(),
        )
    
    def _build_agent(self) -> LlmAgent:
        """Builds the LLM agent for the router agent."""
        return LlmAgent(
            model='gemini-2.5-flash',
            name='router_agent',
            output_schema=RouterOutput,
            instruction="""
    You are an agent who reponds to user queries on behalf of a booking company. The booking company can book flights, hotels & cars rentals.

    Based on user query, you need to suggest next step. Follow below guidelines to choose below next step:
    - BOOK_FLIGHT: If the user shows intent to book a flight.
    - BOOK_HOTEL: If the user shows intent to book a hotel.
    - Otherwise the next step is NONE.

    Your reponses should be in JSON in below schema:
    {{
    "next_step": "NONE | BOOK_FLIGHT | BOOK_HOTEL",
    "next_step_input": "Optional. Not needed in case of next step is NONE. Relevant info from user converstaion required for the selceted next step.",
    "message": "A user visible message, based on the suggested next step. Assume the suggested next step would be auto executed."
    }}

    """,
        )

The agent can be called through run() method and it returns the RouterOutput:

class RouterAgent:
...

    async def run(self, query, session_id) -> RouterOutput:
        session = await self._runner.session_service.get_session(
            app_name=self._agent.name,
            user_id=self._user_id,
            session_id=session_id,
        )
        content = types.Content(
            role='user', parts=[types.Part.from_text(text=query)]
        )
        if session is None:
            session = await self._runner.session_service.create_session(
                app_name=self._agent.name,
                user_id=self._user_id,
                state={},
                session_id=session_id,
            )
        async for event in self._runner.run_async(
            user_id=self._user_id, session_id=session.id, new_message=content
        ):
            if event.is_final_response():
                response = ''
                if (
                    event.content
                    and event.content.parts
                    and event.content.parts[0].text
                ):
                    response = '\n'.join(
                        [p.text for p in event.content.parts if p.text]
                    )
                return RouterOutput.model_validate_json(response)
        
        raise Exception("Router failed")

A2A agent executor

Now we define an A2A executor which consumes this router to first detect the next step and run dummy book_flight & book_hotel actions. It creates Task objects only if the router suggests the next step to be BOOK_FLIGHT or BOOK_HOTEL.

class BookingAgentExecutor(AgentExecutor):
    """Booking AgentExecutor Example."""

    def __init__(self) -> None:
        self.router_agent = RouterAgent()

    async def execute(
        self,
        context: RequestContext,
        event_queue: EventQueue,
    ) -> None:
        query = context.get_user_input()
        task = context.current_task

        router_output = await self.router_agent.run(query, str(uuid.uuid4()))

        if router_output.next_step == RouterActionType.NONE:
            await event_queue.enqueue_event(new_agent_text_message(router_output.message, context_id=context.context_id))
            return

        # Time to create a task.
        if not task:
            task = new_task(context.message)
            await event_queue.enqueue_event(task)

        updater = TaskUpdater(event_queue, task.id, task.context_id)
        await updater.update_status(
            TaskState.working,
            new_agent_text_message(router_output.message, context_id=context.context_id)
        )

        booking_response = ''

        if router_output.next_step == RouterActionType.BOOK_FLIGHT:
            booking_response = await self.book_flight()
        elif router_output.next_step == RouterActionType.BOOK_HOTEL:
            booking_response = await self.book_hotel()

        await updater.add_artifact(
            [Part(root=TextPart(text=booking_response))], name='Booking ID'
        )
        await updater.complete()

    async def book_flight(self) -> str:
        return "PNR: FY1234"

    async def book_hotel(self) -> str:
        return "Hotel Reference No: H789"

Demo

For a quick demo, we are not setting up the entire A2A API server, just the request handler from A2A SDK and directly calling.

request_handler = DefaultRequestHandler(
    agent_executor=BookingAgentExecutor(),
    task_store=InMemoryTaskStore(),
)

import pprint
pp = pprint.PrettyPrinter(indent=4, width=120)

async def send_message(query: str = "hi"):
    task_id = None
    context_id = None
    
    user_message = Message(
        role=Role.user,
        parts=[Part(root=TextPart(text=query))],
        message_id=str(uuid.uuid4()),
        task_id=task_id,
        context_id=context_id,
    )
    params=MessageSendParams(
        message=user_message
    )
    response_stream = request_handler.on_message_send_stream(params=params)

    async for ev in response_stream:
        pp.pprint(ev.model_dump(exclude_none=True))

For simple chit-chat, it returns a Message:

await send_message("hey, could you help book my trip")

{ 'contextId': 'c615c445-8369-4a9a-a1dd-3b2644794fca',
  'kind': 'message',
  'messageId': '7f217339-e889-451e-9ad5-448b0f67a628',
  'parts': [{'kind': 'text', 'text': 'I can help with that. Are you looking to book a flight or a hotel?'}],
  'role': <Role.agent: 'agent'>}

For booking a flight, it returns a Task with all streams of TaskStatusUpdateEvent objects.

await send_message("book a flight from NY to SF")

# Task Created
{ 'contextId': '06bc71c6-f694-444e-845f-51bba6959c3d',
  'history': [ { 'contextId': '06bc71c6-f694-444e-845f-51bba6959c3d',
                 'kind': 'message',
                 'messageId': '6486aae5-4795-4654-9325-0f2c8085b159',
                 'parts': [{'kind': 'text', 'text': 'book a flight from NY to SF'}],
                 'role': <Role.user: 'user'>,
                 'taskId': '4683b10f-30fd-4c16-8def-a67fd7a37a5d'}],
  'id': '4683b10f-30fd-4c16-8def-a67fd7a37a5d',
  'kind': 'task',
  'status': {'state': <TaskState.submitted: 'submitted'>}}

# Working
{ 'contextId': '06bc71c6-f694-444e-845f-51bba6959c3d',
  'final': False,
  'kind': 'status-update',
  'status': { 'message': { 'contextId': '06bc71c6-f694-444e-845f-51bba6959c3d',
                           'kind': 'message',
                           'messageId': 'f2058f4c-2cb7-418c-b930-d9b2a73a8829',
                           'parts': [{'kind': 'text', 'text': 'OK. I am booking a flight from NY to SF.'}],
                           'role': <Role.agent: 'agent'>},
              'state': <TaskState.working: 'working'>,
              'timestamp': '2025-08-15T21:50:47.343116+00:00'},
  'taskId': '4683b10f-30fd-4c16-8def-a67fd7a37a5d'}

# Artifact Created
{ 'artifact': { 'artifactId': 'acd7a390-95c4-44bd-a6fa-92f3e346306e',
                'name': 'Booking ID',
                'parts': [{'kind': 'text', 'text': 'PNR: FY1234'}]},
  'contextId': '06bc71c6-f694-444e-845f-51bba6959c3d',
  'kind': 'artifact-update',
  'taskId': '4683b10f-30fd-4c16-8def-a67fd7a37a5d'}

# Task Finished
{ 'contextId': '06bc71c6-f694-444e-845f-51bba6959c3d',
  'final': True,
  'kind': 'status-update',
  'status': {'state': <TaskState.completed: 'completed'>, 'timestamp': '2025-08-15T21:50:47.343253+00:00'},
  'taskId': '4683b10f-30fd-4c16-8def-a67fd7a37a5d'}

7 Likes

Beautiful, very well explained thank you :folded_hands:

2 Likes

This is really well put together. Nuanced distinction to me initially but this contextualizes things a bit

2 Likes

How could task_id be sent with user message? And, again how would user know the task_id to send it in the first place? Go through logs and add it manually? Could please give some clarity on this, and better, if you could provide code sample for this.

From what I understand from code sample given, task_id is created if RouterActionType = NONE and not according to BOOK_FLIGHT or BOOK_HOTEL. How would that be, some rough idea would help. Escpecially how task_id be dealt with and ensuring that task_id being used for a clarification query is same as already created.

2 Likes

Great explanation @Swapnil_Agarwal! Thank you so much.

Just got one question in line with @waitasecant, about the task_id in the user message.
The main point of using a task in the example is to remove the ambiguity in the user request to change dates. But how will the system be informed about the task_id to send in the user message?

Looking at the UX, would the user send a normal message in the chat, or would there be an indication in the UI of the task the user is talking about?

  • If we consider a normal message in the chat, how would the system know which task the user is talking about?
  • If we consider a UI indication to the user, do you know any good examples?

Thank you!

1 Like

This code creates a Task. Here, the router action is either BOOK_FLIGHT or BOOK_HOTEL. The new_task helper method creates a task_id internally. This task is then propagated back from server as response.

The client is supposed to look at A2A server responses.

When user sends message I want to book a flight from NY to SF, the A2A server would return a Task response. Look at message/send API. This Task response has the task_id, which the client can use for future messages.

Next time, the user is sending a message specific to that task to the A2A server, they can attach this relevant task_id in the message inside MessageSendConfiguration.

1 Like

Please check my reply on how to extract the task_id from A2A server response.

Assuming the server replies with two Task’s: one for booking flight and another for booking a hotel.

To help disambiguate, we want to tag task_id when sending message. For a simple UI as a client, there could be many ways, the user can be nudged towards that. Here are some UX ideas of the top of my mind:

  1. Since we have task_ids from previous tasks, the UI can allow tagging them in the message. Something like @hotel-456. The UI would then attach that task_id, when sending that message.
  2. UI can allow “quoting” or “replying to” that last message which created the task, again indicating the task-id.
  3. UI can show running tasks, in a side panel or as threads. User can directly continue chatting to those tasks, in those threads.

I hope this helps kick-start some UX ideas and let us know what you created in the comments.

1 Like

Hi, I found this post while searching for cases where a Message would be useful as a response object.

While I understand why user’s requests use Messages. I’m still wondering why one may want to use them to convey a response. And I think the arguments in “What if we only used Tasks?” are quite slim.

Task Proliferation: A completed task closely resembles a Message with a few extra fields. Handling them does not introduce significant overhead. On the other hand, mixing Tasks and Messages complicates system design, as the example of upgrading a Message to a Task after a timeout illustrates. And it also complicates the client’s implementation which requires to support both.

History Review: End users rarely see raw agent responses. This is a UX issue that can be solved by looking at artifacts and task updates with code, and presenting the response with the proper formatting as a result.

Additionally, it is unclear how an agent should determine what qualifies as a non-task. Simple queries like “Tell me what you can do” are not so different from “Tell me what are the next flights from SFO,” making it difficult to discriminate which responses deserve a Task Vs a Message.

Ultimately, I am still inclined to think that Messages as responses have no real advantages over Tasks as responses and could be made a deprecated thing.

Thoughts ?