DEV Community

Koichi
Koichi

Posted on

[ADK] Passing Session State to Remote Agents Over Stateless A2A

Intro

ADK ships with a mechanism for calling remote agents over the A2A protocol. It's handy, but A2A is stateless by design, so the client-side state doesn't get carried over to the server side. That's intentional — A2A is built to keep distributed agents loosely coupled.

For ADK developers, though, state is an everyday concept — a shared memory used to pass information between agents. It's a little jarring when something you rely on with local sub-agents just... stops working the moment the agent goes remote.

This post tackles exactly that tension: A2A is stateless, but I still want my state to flow through to the remote agent within the ADK world. I'll walk through a minimal sample that uses the metadata field of A2A requests as a transport channel to inject client-side state into the remote agent's state.

The sample code is in this repository:

GitHub logo Koichi73 / a2a-share-state

A minimal example of sharing session state between ADK agents over the A2A protocol.

ADK A2A Share State

A minimal example of sharing session state between ADK agents over the A2A protocol.

ADK's A2A communication is stateless by design — session state is not shared across process boundaries. This sample demonstrates a pattern to bridge that gap by forwarding client-side session state through A2A request metadata, making it available on the server side for instruction template resolution.

Prerequisites

  • Python 3.11+
  • uv
  • Google Cloud project with Vertex AI API enabled
  • Application Default Credentials configured (gcloud auth application-default login)

Setup

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

Fill in .env:

GOOGLE_GENAI_USE_VERTEXAI=1
GOOGLE_CLOUD_PROJECT=your-project-id
GOOGLE_CLOUD_LOCATION=us-central1

Install dependencies:

uv sync
Enter fullscreen mode Exit fullscreen mode

Running

Terminal 1 — start the A2A server:

bash start_a2a_server.sh
Enter fullscreen mode Exit fullscreen mode

The server starts at http://localhost:8001.

Terminal 2 — run the client agent:

uv run adk run client
# or use the web UI
uv run adk web
Enter fullscreen mode Exit fullscreen mode

The root agent calls the remote greet_agent and receives a response like:

Goal

The goal is to make user_name, which we put into the client-side state, resolvable from {user_name} in the remote agent's instruction.

goal

Concretely, we set state on the client like this:

# Client side
callback_context.state["user_name"] = "Alice"
Enter fullscreen mode Exit fullscreen mode

And we want the remote agent to resolve the following instruction and respond with Hello Alice! How are you doing today?:

# Server side
greet_agent = Agent(
    instruction="""
    Please greet according to the format.
    Hello {user_name}! How are you doing today?
    """,
    ...
)
Enter fullscreen mode Exit fullscreen mode

Architecture

Here's how the pieces fit together. A RequestInterceptor on the client pushes the state into A2A metadata, and a before_agent_callback on the server writes it back into the session state.

Architecture

There are two key points:

  • Client side: in RequestInterceptor.before_request, copy ctx.session.state into parameters.request_metadata
  • Server side: in before_agent_callback, write run_config.custom_metadata['a2a_metadata'] back into callback_context.state

The part where the incoming metadata gets expanded into RunConfig.custom_metadata['a2a_metadata'] on the server is handled automatically by ADK's request_converter, so there's nothing for us to do there.

Implementation

Directory layout

share_state/
├── client/
│   ├── __init__.py
│   └── agent.py          # root_agent + RemoteA2aAgent + RequestInterceptor
├── server/
│   └── remote_agent.py   # greet_agent + before_agent_callback
├── start_a2a_server.sh   # launches the server with uvicorn
├── pyproject.toml
└── .env
Enter fullscreen mode Exit fullscreen mode

Set Vertex AI env vars in .env:

GOOGLE_GENAI_USE_VERTEXAI=1
GOOGLE_CLOUD_PROJECT=your-project-id
GOOGLE_CLOUD_LOCATION=us-central1
Enter fullscreen mode Exit fullscreen mode

Include ADK's A2A extras in pyproject.toml:

[project]
dependencies = [
    "google-adk[a2a]>=1.27.4",
    "python-dotenv>=1.2.2",
]
Enter fullscreen mode Exit fullscreen mode

Server side: expand received metadata into state

The server's job is to copy values from the A2A request's metadata back into callback_context.state.

server/remote_agent.py:

from google.adk.a2a.utils.agent_to_a2a import to_a2a
from google.adk.agents import Agent
from google.adk.agents.callback_context import CallbackContext


def _inject_metadata_into_state(callback_context: CallbackContext) -> None:
    """Expand A2A metadata into the session state."""
    run_config = callback_context.run_config
    for key, value in (run_config.custom_metadata.get("a2a_metadata") or {}).items():
        callback_context.state[key] = value
Enter fullscreen mode Exit fullscreen mode

ADK automatically repackages the received MessageSendParams.metadata into RunConfig.custom_metadata['a2a_metadata']. This happens inside google.adk.a2a.converters.request_converter, so no extra plumbing is needed on our end.

Writes to callback_context.state are reflected in the live session.state immediately. before_agent_callback runs before the LLM flow expands the instruction template, so {user_name} is resolved against the value we just wrote.

Next, pass the callback to before_agent_callback and turn the agent into an A2A server with to_a2a():

greet_agent = Agent(
    model="gemini-3-flash-preview",
    name="greet_agent",
    description="Greet according to the format.",
    instruction="""
    Please greet according to the format.
    Hello {user_name}! How are you doing today? 
    """,
    before_agent_callback=_inject_metadata_into_state,
)


a2a_app = to_a2a(greet_agent, port=8001)
Enter fullscreen mode Exit fullscreen mode

Launch the server with uvicorn:

# start_a2a_server.sh
set -a
source .env
set +a

uv run uvicorn server.remote_agent:a2a_app --host localhost --port 8001
Enter fullscreen mode Exit fullscreen mode

Client side: move state into A2A metadata

On the client, we prepare a RequestInterceptor that copies state into request_metadata just before sending the A2A request.

client/agent.py:

from typing import Any
from a2a.types import Message as A2AMessage
from google.adk.a2a.agent.config import (
    A2aRemoteAgentConfig,
    ParametersConfig,
    RequestInterceptor,
)
from google.adk.agents import Agent
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
from google.adk.tools.agent_tool import AgentTool

async def _forward_state_as_a2a_metadata(
    ctx: InvocationContext,
    a2a_request: A2AMessage,
    parameters: ParametersConfig,
) -> tuple[A2AMessage, ParametersConfig]:
    """before_request: Include all session state keys in the A2A metadata."""
    payload: dict[str, Any] = dict(ctx.session.state)
    if payload:
        parameters.request_metadata = {
            **(parameters.request_metadata or {}),
            **payload,
        }
    return a2a_request, parameters
Enter fullscreen mode Exit fullscreen mode

Whatever you put into parameters.request_metadata is forwarded to a2a.client.send_message(request_metadata=...), and ultimately sent over JSON-RPC as MessageSendParams(message=..., metadata=request_metadata).

Since this is a minimal sample, I'm just dumping the entire ctx.session.state into the metadata without any filtering. In production, I'd recommend whitelisting only the keys you actually want to send.

Next, register the interceptor on the RemoteA2aAgent:

greet_agent = RemoteA2aAgent(
    name="greet_agent",
    description="Greet according to the format.",
    agent_card="http://localhost:8001/.well-known/agent-card.json",
    config=A2aRemoteAgentConfig(
        request_interceptors=[
            RequestInterceptor(before_request=_forward_state_as_a2a_metadata),
        ]
    ),
)
Enter fullscreen mode Exit fullscreen mode

RemoteA2aAgent accepts a list of RequestInterceptors via A2aRemoteAgentConfig, and calls the hooks around each A2A request.

Finally, define root_agent and seed a fixed value into state for the demo:

def _seed_state(callback_context: CallbackContext) -> None:
    """For demo purposes: set a fixed value to the state."""
    callback_context.state.setdefault("user_name", "Alice")


root_agent = Agent(
    name="root_agent",
    model="gemini-3-flash-preview",
    instruction="Please use the tool to greet the user.",
    tools=[AgentTool(greet_agent)],
    before_agent_callback=_seed_state,
)
Enter fullscreen mode Exit fullscreen mode

root_agent invokes the remote agent as a tool via AgentTool(greet_agent). AgentTool.run_async passes the parent's tool_context.state.to_dict() as-is to the sub-runner's new session, so user_name="Alice" is visible to the interceptor via the sub-invocation's ctx.session.state.

Trying it out

Start the A2A server and the client in separate terminals:

# Terminal 1: A2A server
bash start_a2a_server.sh
Enter fullscreen mode Exit fullscreen mode
# Terminal 2: client agent
uv run adk web
Enter fullscreen mode Exit fullscreen mode

From the ADK Web UI, send any message to root_agent. It will call the remote greet_agent and return a response like:

Hello Alice! How are you doing today?
Enter fullscreen mode Exit fullscreen mode

{user_name} has been resolved with "Alice" from the client-side state — proof that the state made it to the server via A2A metadata.

Summary

  • ADK's A2A communication is stateless by design, so the client-side state doesn't reach the server as-is
  • You can use the A2A request's metadata field as a transport channel to inject client-side state into the remote agent's instruction template
  • All you need is a RequestInterceptor.before_request on the client to push state into metadata, and a before_agent_callback on the server to write metadata back into state
  • ADK automatically expands the incoming metadata into RunConfig.custom_metadata['a2a_metadata'], so the plumbing on each side fits in just a few lines

Sample code is here:

GitHub logo Koichi73 / a2a-share-state

A minimal example of sharing session state between ADK agents over the A2A protocol.

ADK A2A Share State

A minimal example of sharing session state between ADK agents over the A2A protocol.

ADK's A2A communication is stateless by design — session state is not shared across process boundaries. This sample demonstrates a pattern to bridge that gap by forwarding client-side session state through A2A request metadata, making it available on the server side for instruction template resolution.

Prerequisites

  • Python 3.11+
  • uv
  • Google Cloud project with Vertex AI API enabled
  • Application Default Credentials configured (gcloud auth application-default login)

Setup

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

Fill in .env:

GOOGLE_GENAI_USE_VERTEXAI=1
GOOGLE_CLOUD_PROJECT=your-project-id
GOOGLE_CLOUD_LOCATION=us-central1

Install dependencies:

uv sync
Enter fullscreen mode Exit fullscreen mode

Running

Terminal 1 — start the A2A server:

bash start_a2a_server.sh
Enter fullscreen mode Exit fullscreen mode

The server starts at http://localhost:8001.

Terminal 2 — run the client agent:

uv run adk run client
# or use the web UI
uv run adk web
Enter fullscreen mode Exit fullscreen mode

The root agent calls the remote greet_agent and receives a response like:

That's it — hope this helps if you've been wrestling with state and remote agents in ADK!

Top comments (0)