Spring Boot 4.1 · Spring AI 2.0 · AWS Bedrock · Agent-to-Agent Protocol
A self-contained demonstration of the Agent-to-Agent (A2A) protocol built on Spring Boot 4.1. Two specialised agents collaborate transparently to answer a single customer question:
"Where is my order ORD-1001?"
The order-status agent acts simultaneously as an A2A server (answering the caller) and an A2A client (delegating shipment tracking to the downstream shipping-tracker agent). The caller never knows a second agent is involved — that encapsulation is exactly what makes A2A composable.
- Architecture
- Prerequisites
- Quick Start
- Available Profiles
- Running the Demo
- API Reference
- Demo Orders
- Configuration
- Test Suite
- Project Layout
- How It Works
- Extending the Agents
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#1e2a3a",
"primaryTextColor": "#e2e8f0",
"primaryBorderColor": "#4a9eff",
"lineColor": "#64b5f6",
"secondaryColor": "#162032",
"tertiaryColor": "#0d1b2a",
"clusterBkg": "#1a2535",
"clusterBorder": "#334155",
"titleColor": "#e2e8f0",
"edgeLabelBackground": "#1e2a3a",
"nodeBorder": "#4a9eff",
"fontFamily": "ui-monospace, monospace"
}
}}%%
flowchart TB
subgraph CLIENT["⚙️ JVM 3 — profile: order-client (port: random)"]
OCR["OrderClientRunner\nasked a question → prints the answer → exits"]
end
subgraph STATUS["🔄 JVM 2 — profile: order-status (port: 9601)"]
OSA["order-status-agent\nskill: order-status\nA2A server ↔ A2A client simultaneously"]
end
subgraph SHIPPING["📦 JVM 1 — profile: order-shipping (port: 9600)"]
STA["shipping-tracker-agent\nskill: track-shipment"]
end
OCR -- "A2A · JSON-RPC · message/send" --> STATUS
OSA -- "A2A · JSON-RPC · message/send" --> SHIPPING
style CLIENT fill:#0d1b2a,stroke:#334155,color:#94a3b8
style STATUS fill:#0a1f35,stroke:#4a9eff,color:#e2e8f0
style SHIPPING fill:#0a2a1f,stroke:#22c55e,color:#e2e8f0
📊 See DIAGRAMS.md for detailed Mermaid diagrams covering the system architecture, sequence flows, A2A protocol contract, task state machine, and package structure.
| Tool | Version | Notes |
|---|---|---|
| JDK | 25 (or 21+) | Build targets --release 25; runs on any JDK ≥ 21 |
| Maven Wrapper | bundled | Use ./mvnw — no Maven installation required |
| AWS credentials | optional | Only needed for live LLM calls; the demo skills are deterministic |
Note: AWS credentials are resolved from the standard provider chain — environment variables,
~/.aws/credentials, IAM instance profile, etc. The two demo profiles do not call the LLM directly; Bedrock is wired so you can add LLM-backed reasoning without touching infrastructure.
# 1 — Clone and build
git clone <repo-url>
cd order-status
./mvnw clean package -DskipTests
# 2 — Run all tests
./mvnw clean test
# 3 — Start both agents (two separate terminals)
./mvnw spring-boot:run -Dspring-boot.run.profiles=order-shipping # terminal 1
./mvnw spring-boot:run -Dspring-boot.run.profiles=order-status # terminal 2
# 4 — Ask a question (third terminal)
./mvnw spring-boot:run \
-Dspring-boot.run.profiles=order-client \
'-Dspring-boot.run.arguments=Where is my order ORD-1003?'Expected output:
Order ORD-1003 — Running shoes (carrier: DHL, tracking #DHL7788991122).
→ Delivered — signed for by 'M. SILVA' at 09:42.
| Profile | Role | Port | Key Bean |
|---|---|---|---|
order-shipping |
Downstream A2A server — answers tracking queries | 9600 | ShippingTrackerConfig |
order-status |
Upstream A2A server and A2A client | 9601 | OrderStatusConfig |
order-client |
CLI test client — one shot, then exits | random | OrderClientRunner |
Only one profile is active per JVM process; the three processes collaborate over HTTP.
./mvnw spring-boot:run -Dspring-boot.run.profiles=order-shippingExposes:
GET /.well-known/agent-card.json— agent discoveryPOST /a2a— JSON-RPC endpoint (message/send,message/stream,tasks/get)
./mvnw spring-boot:run -Dspring-boot.run.profiles=order-statusThis agent registers itself as an A2A server on port 9601 and wires an A2aClient that points to the shipping-tracker at port 9600.
# Default question
./mvnw spring-boot:run -Dspring-boot.run.profiles=order-client
# Custom question (override via program arguments)
./mvnw spring-boot:run \
-Dspring-boot.run.profiles=order-client \
'-Dspring-boot.run.arguments=Where is ORD-1002?'Both agents expose the same HTTP surface (A2A protocol):
GET /.well-known/agent-card.jsoncurl -s http://localhost:9601/.well-known/agent-card.json | jqSample response
{
"name": "order-status-agent",
"description": "Answers 'where is my order?' by delegating shipment tracking to a downstream A2A agent.",
"url": "http://localhost:9601",
"version": "1.0.0",
"capabilities": { "streaming": false, "pushNotifications": false },
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [
{
"id": "order-status",
"name": "Order Status",
"description": "Tells a customer where their order is — combines order data with live shipment tracking.",
"tags": ["ecommerce", "customer-service", "a2a-to-a2a"],
"examples": ["ORD-1001", "Where is ORD-1003?"]
}
]
}POST /a2a
Content-Type: application/jsoncurl -s -X POST http://localhost:9601/a2a \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"messageId": "m1",
"role": "user",
"parts": [{ "kind": "text", "text": "Where is ORD-1002?" }]
}
}
}' | jqPOST /a2a/stream
Content-Type: application/json
Accept: text/event-streamcurl -sN -X POST http://localhost:9601/a2a/stream \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "message/stream",
"params": {
"message": {
"messageId": "m2",
"role": "user",
"parts": [{ "kind": "text", "text": "Where is ORD-1001?" }]
}
}
}'curl -s -X POST http://localhost:9601/a2a \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tasks/get",
"params": { "id": "<task-uuid>" }
}' | jqcurl http://localhost:9601/actuator/health
curl http://localhost:9601/actuator/metrics| Order ID | Product | Carrier | Tracking # | Status |
|---|---|---|---|---|
| ORD-1001 | Mechanical keyboard | UPS | 1Z999AA10123456784 |
In transit |
| ORD-1002 | Coffee beans (1 kg) | FedEx | FX555000123 |
Out for delivery |
| ORD-1003 | Running shoes | DHL | DHL7788991122 |
Delivered |
| ORD-1004 | USB-C cable | USPS | USPS940010000000 |
Label created |
| Variable | Default | Description |
|---|---|---|
AWS_REGION |
us-east-1 |
AWS region for Bedrock |
AWS_ACCESS_KEY_ID |
— | AWS access key (or use instance profile) |
AWS_SECRET_ACCESS_KEY |
— | AWS secret key |
BEDROCK_MODEL |
anthropic.claude-3-5-sonnet-20241022-v2:0 |
Bedrock model ID |
| Property | Default | Description |
|---|---|---|
spring.ai.a2a.server.enabled |
false |
Enable the A2A RestController |
spring.ai.a2a.server.endpoint-path |
/a2a |
JSON-RPC endpoint path |
spring.ai.a2a.server.card.* |
— | Agent Card metadata |
spring.ai.a2a.client.base-url |
http://localhost:9601 |
Default upstream A2A base URL |
order-status.shipping-url |
http://localhost:9600 |
Shipping-tracker URL for the order-status agent |
spring.ai.bedrock.aws.region |
us-east-1 |
Overridden by AWS_REGION |
spring.ai.bedrock.converse.chat.options.model |
Claude 3.5 Sonnet | Override with BEDROCK_MODEL |
# Use a different Bedrock model
BEDROCK_MODEL=anthropic.claude-3-haiku-20240307-v1:0 \
./mvnw spring-boot:run -Dspring-boot.run.profiles=order-status
# Point to a different shipping-tracker URL
./mvnw spring-boot:run \
-Dspring-boot.run.profiles=order-status \
-Dspring-boot.run.arguments=--order-status.shipping-url=http://tracker.internal:9600./mvnw clean test| Test Class | Type | What It Covers |
|---|---|---|
OrderStatusSkillsTest |
Unit | order-status skill logic with a mocked A2aClient |
ShippingTrackerConfigTest |
Unit | Agent card metadata + track-shipment skill handler |
TaskAssemblerTest |
Unit | Folding of streaming TaskEvents into a final Task |
TaskStoreTest |
Unit | In-memory task-store contract |
OrderStatusApplicationTests |
Spring context | Smoke-test — server disabled, Bedrock stubbed |
A2aControllerIntegrationTest |
Integration | Full HTTP test: boots order-shipping profile, exercises message/send + agent-card endpoint |
Test reports land in target/surefire-reports/.
src/main/java/com/example/order_status/
├── OrderStatusApplication.java ← @SpringBootApplication entry point
├── OrderStatusConfig.java ← Upstream agent (profile: order-status, port 9601)
│ Skills: order-status
│ Depends on: A2aClient → shipping-tracker
├── ShippingTrackerConfig.java ← Downstream agent (profile: order-shipping, port 9600)
│ Skills: track-shipment
├── OrderClientRunner.java ← CLI test client (profile: order-client)
└── a2a/
├── spec/ ← A2A protocol records (immutable value types)
│ ├── AgentCard.java ← Agent identity + capabilities metadata
│ ├── AgentSkill.java ← Discrete capability descriptor
│ ├── AgentCapabilities.java ← Supported protocol features
│ ├── Message.java ← User/agent message with multi-modal Parts
│ ├── Part.java ← TextPart | DataPart | FilePart (sealed)
│ ├── Task.java ← Mutable task with status + artifacts
│ ├── TaskEvent.java ← SSE event (StatusUpdate | ArtifactUpdate)
│ └── JsonRpc.java ← JSON-RPC 2.0 Request / Response / Error
├── server/ ← Auto-configured server side
│ ├── A2aController.java ← RestController: /a2a + /.well-known/agent-card.json
│ ├── A2aServerAutoConfiguration.java ← Registers TaskStore + SkillHandler wiring
│ ├── A2aServerProperties.java ← Binds spring.ai.a2a.server.*
│ ├── SkillHandler.java ← Strategy interface for skill implementations
│ ├── TaskAssembler.java ← Folds SSE stream → complete Task
│ └── TaskStore.java ← In-memory task repository
└── client/ ← Thin A2A HTTP client
├── A2aClient.java ← fetchAgentCard / sendMessage / streamMessage / getTask
├── A2aClientBuilder.java ← Fluent builder (RestClient + WebClient)
└── A2aClientAutoConfiguration.java ← Auto-wires default A2aClient bean
The A2A protocol defines how agents discover and communicate with each other using JSON-RPC 2.0 over HTTP. Every agent:
- Advertises itself at
GET /.well-known/agent-card.json— name, skills, input/output modes. - Accepts work at
POST /a2aviamessage/send(synchronous) or/a2a/streamviamessage/stream(SSE). - Returns a
Task— a structured object with status (submitted → working → completed/failed) and output artifacts.
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#1e2a3a",
"primaryTextColor": "#e2e8f0",
"primaryBorderColor": "#4a9eff",
"lineColor": "#64b5f6",
"secondaryColor": "#162032",
"tertiaryColor": "#0d1b2a",
"noteBkgColor": "#1a2535",
"noteTextColor": "#94a3b8",
"noteBorderColor": "#334155",
"activationBkgColor": "#1e3a5f",
"activationBorderColor":"#4a9eff",
"actorBkg": "#0d1b2a",
"actorBorder": "#4a9eff",
"actorTextColor": "#e2e8f0",
"actorLineColor": "#334155",
"signalColor": "#64b5f6",
"signalTextColor": "#e2e8f0",
"fontFamily": "ui-monospace, monospace"
}
}}%%
sequenceDiagram
autonumber
participant OCR as OrderClientRunner
participant OSA as order-status-agent<br/>:9601
participant OB as Order Book<br/>(in-memory)
participant STA as shipping-tracker-agent<br/>:9600
OCR ->> OSA : message/send<br/>"Where is ORD-1003?"
activate OSA
OSA ->> OSA : 1. Parse order ID<br/>(regex: ORD-\d+)
OSA ->> OB : 2. lookup("ORD-1003")
OB -->> OSA : Order {product, carrier, tracking#}
OSA ->> STA : 3. message/send — track-shipment skill
activate STA
STA -->> OSA : Task {state: completed, artifact: "Delivered..."}
deactivate STA
OSA ->> OSA : 4. Compose customer-facing reply
OSA -->> OCR : 5. Task {state: completed,<br/>artifact: "Order ORD-1003 — Running shoes..."}
deactivate OSA
Note over OCR : Prints artifact text → exits
message/send→POST /a2a— fire and wait; best for short, deterministic skills.message/stream→POST /a2a/stream— Server-Sent Events; best for LLM-backed skills where the response is generated token by token.
Both APIs surface the same Task and TaskEvent data model.
- Implement
SkillHandler:
@Component
@Profile("order-status") // attach to the right agent profile
public class RefundStatusSkill implements SkillHandler {
@Override
public String skillId() { return "refund-status"; }
@Override
public Task handle(Message request, Task seed) {
// ... look up refund data ...
return /* completed Task with artifact */;
}
}- Register the skill ID in the
AgentCardbean insideOrderStatusConfig.
Change BEDROCK_MODEL to any model enabled in your AWS account:
# Switch to Claude 3 Haiku for lower latency
export BEDROCK_MODEL=anthropic.claude-3-haiku-20240307-v1:0
# Switch to Amazon Titan
export BEDROCK_MODEL=amazon.titan-text-express-v1Replace the TRACKING_DB map in ShippingTrackerConfig with a call to your carrier's REST/SOAP API. The SkillHandler contract is unchanged.